diff --git a/FirebaseAuth/Sources/Swift/Auth/Auth.swift b/FirebaseAuth/Sources/Swift/Auth/Auth.swift index b2b4de994ae..0dd2c1a1aff 100644 --- a/FirebaseAuth/Sources/Swift/Auth/Auth.swift +++ b/FirebaseAuth/Sources/Swift/Auth/Auth.swift @@ -53,9 +53,7 @@ import FirebaseCoreExtension open func application(_ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: Error) { - kAuthGlobalWorkQueue.sync { - self.tokenManager.cancel(withError: error) - } + tokenManagerGet().cancel(withError: error) } open func application(_ application: UIApplication, @@ -76,56 +74,51 @@ import FirebaseCoreExtension @available(iOS 13, tvOS 13, macOS 10.15, macCatalyst 13, watchOS 7, *) extension Auth: AuthInterop { + func getTokenInternal(forcingRefresh forceRefresh: Bool) { + // Enable token auto-refresh if not already enabled. + if !autoRefreshTokens { + AuthLog.logInfo(code: "I-AUT000002", message: "Token auto-refresh enabled.") + autoRefreshTokens = true + scheduleAutoTokenRefresh() + + #if canImport(UIKit) // Is a similar mechanism needed on macOS? + applicationDidBecomeActiveObserver = + NotificationCenter.default.addObserver( + forName: UIApplication.didBecomeActiveNotification, + object: nil, queue: nil + ) { notification in + self.isAppInBackground = false + if !self.autoRefreshScheduled { + self.scheduleAutoTokenRefresh() + } + } + applicationDidEnterBackgroundObserver = + NotificationCenter.default.addObserver( + forName: UIApplication.didEnterBackgroundNotification, + object: nil, queue: nil + ) { notification in + self.isAppInBackground = true + } + #endif + } + } + /// Retrieves the Firebase authentication token, possibly refreshing it if it has expired. + /// TODO: Switch protocol and implementation to Swift when clients are all Swift. /// /// This method is not for public use. It is for Firebase clients of AuthInterop. @objc(getTokenForcingRefresh:withCallback:) public func getToken(forcingRefresh forceRefresh: Bool, - completion callback: @escaping (String?, Error?) -> Void) { - kAuthGlobalWorkQueue.async { [weak self] in - if let strongSelf = self { - // Enable token auto-refresh if not already enabled. - if !strongSelf.autoRefreshTokens { - AuthLog.logInfo(code: "I-AUT000002", message: "Token auto-refresh enabled.") - strongSelf.autoRefreshTokens = true - strongSelf.scheduleAutoTokenRefresh() - - #if os(iOS) || os(tvOS) // TODO(ObjC): Is a similar mechanism needed on macOS? - strongSelf.applicationDidBecomeActiveObserver = - NotificationCenter.default.addObserver( - forName: UIApplication.didBecomeActiveNotification, - object: nil, queue: nil - ) { notification in - if let strongSelf = self { - strongSelf.isAppInBackground = false - if !strongSelf.autoRefreshScheduled { - strongSelf.scheduleAutoTokenRefresh() - } - } - } - strongSelf.applicationDidEnterBackgroundObserver = - NotificationCenter.default.addObserver( - forName: UIApplication.didEnterBackgroundNotification, - object: nil, queue: nil - ) { notification in - if let strongSelf = self { - strongSelf.isAppInBackground = true - } - } - #endif - } - } - // Call back with 'nil' if there is no current user. - guard let strongSelf = self, let currentUser = strongSelf.currentUser else { - DispatchQueue.main.async { - callback(nil, nil) + completion: @escaping (String?, Error?) -> Void) { + Task { + do { + let token = try await authWorker.getToken(forcingRefresh: forceRefresh) + await MainActor.run { + completion(token, nil) } - return - } - // Call back with current user token. - currentUser.internalGetToken(forceRefresh: forceRefresh) { token, error in - DispatchQueue.main.async { - callback(token, error) + } catch { + await MainActor.run { + completion(nil, error) } } } @@ -179,14 +172,10 @@ extension Auth: AuthInterop { /// The string used to set this property must be a language code that follows BCP 47. @objc open var languageCode: String? { get { - kAuthGlobalWorkQueue.sync { - requestConfiguration.languageCode - } + getLanguageCode() } set(val) { - kAuthGlobalWorkQueue.sync { - requestConfiguration.languageCode = val - } + setLanguageCode(val) } } @@ -213,60 +202,6 @@ extension Auth: AuthInterop { /// this domain when signing in. This domain must be allowlisted in the Firebase Console. @objc open var customAuthDomain: String? - /// Sets the `currentUser` on the receiver to the provided user object. - /// - Parameters: - /// - user: The user object to be set as the current user of the calling Auth instance. - /// - completion: Optionally; a block invoked after the user of the calling Auth instance has - /// been updated or an error was encountered. - @objc open func updateCurrentUser(_ user: User?, completion: ((Error?) -> Void)? = nil) { - kAuthGlobalWorkQueue.async { - guard let user else { - let error = AuthErrorUtils.nullUserError(message: nil) - Auth.wrapMainAsync(completion, error) - return - } - let updateUserBlock: (User) -> Void = { user in - do { - try self.updateCurrentUser(user, byForce: true, savingToDisk: true) - Auth.wrapMainAsync(completion, nil) - } catch { - Auth.wrapMainAsync(completion, error) - } - } - if user.requestConfiguration.apiKey != self.requestConfiguration.apiKey { - // If the API keys are different, then we need to confirm that the user belongs to the same - // project before proceeding. - user.requestConfiguration = self.requestConfiguration - user.reload { error in - if let error { - Auth.wrapMainAsync(completion, error) - return - } - updateUserBlock(user) - } - } else { - updateUserBlock(user) - } - } - } - - /// Sets the `currentUser` on the receiver to the provided user object. - /// - Parameter user: The user object to be set as the current user of the calling Auth instance. - /// - Parameter completion: Optionally; a block invoked after the user of the calling Auth - /// instance has been updated or an error was encountered. - @available(iOS 13, tvOS 13, macOS 10.15, macCatalyst 13, watchOS 7, *) - open func updateCurrentUser(_ user: User) async throws { - return try await withCheckedThrowingContinuation { continuation in - self.updateCurrentUser(user) { error in - if let error { - continuation.resume(throwing: error) - } else { - continuation.resume() - } - } - } - } - /// [Deprecated] Fetches the list of all sign-in methods previously used for the provided /// email address. This method returns an empty list when [Email Enumeration /// Protection](https://cloud.google.com/identity-platform/docs/admin/email-enumeration-protection) @@ -288,16 +223,15 @@ extension Auth: AuthInterop { #endif // !FIREBASE_CI @objc open func fetchSignInMethods(forEmail email: String, completion: (([String]?, Error?) -> Void)? = nil) { - kAuthGlobalWorkQueue.async { - let request = CreateAuthURIRequest(identifier: email, - continueURI: "http:www.google.com", - requestConfiguration: self.requestConfiguration) - Task { - do { - let response = try await AuthBackend.call(with: request) - Auth.wrapMainAsync(callback: completion, withParam: response.signinMethods, error: nil) - } catch { - Auth.wrapMainAsync(callback: completion, withParam: nil, error: error) + Task { + do { + let result = try await fetchSignInMethods(forEmail: email) + await MainActor.run { + completion?(result, nil) + } + } catch { + await MainActor.run { + completion?(nil, error) } } } @@ -319,15 +253,7 @@ extension Auth: AuthInterop { message: "`fetchSignInMethods` is deprecated and will be removed in a future release. This method returns an empty list when Email Enumeration Protection is enabled." ) open func fetchSignInMethods(forEmail email: String) async throws -> [String] { - return try await withCheckedThrowingContinuation { continuation in - self.fetchSignInMethods(forEmail: email) { methods, error in - if let methods { - continuation.resume(returning: methods) - } else { - continuation.resume(throwing: error!) - } - } - } + return try await authWorker.fetchSignInMethods(forEmail: email) } /// Signs in using an email address and password. @@ -352,61 +278,20 @@ extension Auth: AuthInterop { @objc open func signIn(withEmail email: String, password: String, completion: ((AuthDataResult?, Error?) -> Void)? = nil) { - kAuthGlobalWorkQueue.async { - let decoratedCallback = self.signInFlowAuthDataResultCallback(byDecorating: completion) - Task { - do { - let authData = try await self.internalSignInAndRetrieveData( - withEmail: email, - password: password - ) - decoratedCallback(authData, nil) - } catch { - decoratedCallback(nil, error) + Task { + do { + let result = try await signIn(withEmail: email, password: password) + await MainActor.run { + completion?(result, nil) + } + } catch { + await MainActor.run { + completion?(nil, error) } } } } - /// Signs in using an email address and password. - /// - /// When [Email Enumeration - /// Protection](https://cloud.google.com/identity-platform/docs/admin/email-enumeration-protection) - /// is enabled, this method throws in case of an invalid email/password. - /// - /// Possible error codes: - /// * `AuthErrorCodeOperationNotAllowed` - Indicates that email and password - /// accounts are not enabled. Enable them in the Auth section of the - /// Firebase console. - /// * `AuthErrorCodeUserDisabled` - Indicates the user's account is disabled. - /// * `AuthErrorCodeWrongPassword` - Indicates the user attempted - /// sign in with an incorrect password. - /// * `AuthErrorCodeInvalidEmail` - Indicates the email address is malformed. - /// - Parameter email: The user's email address. - /// - Parameter password: The user's password. - /// - Returns: The signed in user. - func internalSignInUser(withEmail email: String, - password: String) async throws -> User { - let request = VerifyPasswordRequest(email: email, - password: password, - requestConfiguration: requestConfiguration) - if request.password.count == 0 { - throw AuthErrorUtils.wrongPasswordError(message: nil) - } - #if os(iOS) - let response = try await injectRecaptcha(request: request, - action: AuthRecaptchaAction.signInWithPassword) - #else - let response = try await AuthBackend.call(with: request) - #endif - return try await completeSignIn( - withAccessToken: response.idToken, - accessTokenExpirationDate: response.approximateExpirationDate, - refreshToken: response.refreshToken, - anonymous: false - ) - } - /// Signs in using an email address and password. /// /// Possible error codes: @@ -420,18 +305,11 @@ extension Auth: AuthInterop { /// - Parameter email: The user's email address. /// - Parameter password: The user's password. /// - Returns: The `AuthDataResult` after a successful signin. - @available(iOS 13, tvOS 13, macOS 10.15, macCatalyst 13, watchOS 7, *) @discardableResult open func signIn(withEmail email: String, password: String) async throws -> AuthDataResult { - return try await withCheckedThrowingContinuation { continuation in - self.signIn(withEmail: email, password: password) { authData, error in - if let authData { - continuation.resume(returning: authData) - } else { - continuation.resume(throwing: error!) - } - } - } + let result = try await authWorker.signIn(withEmail: email, password: password) + try await authWorker.updateCurrentUser(result.user, byForce: false, savingToDisk: true) + return result } /// Signs in using an email address and email sign-in link. @@ -451,16 +329,15 @@ extension Auth: AuthInterop { @objc open func signIn(withEmail email: String, link: String, completion: ((AuthDataResult?, Error?) -> Void)? = nil) { - kAuthGlobalWorkQueue.async { - let decoratedCallback = self.signInFlowAuthDataResultCallback(byDecorating: completion) - let credential = EmailAuthCredential(withEmail: email, link: link) - Task { - do { - let authData = try await self.internalSignInAndRetrieveData(withCredential: credential, - isReauthentication: false) - decoratedCallback(authData, nil) - } catch { - decoratedCallback(nil, error) + Task { + do { + let result = try await signIn(withEmail: email, link: link) + await MainActor.run { + completion?(result, nil) + } + } catch { + await MainActor.run { + completion?(nil, error) } } } @@ -478,17 +355,10 @@ extension Auth: AuthInterop { /// - Parameter email: The user's email address. /// - Parameter link: The email sign-in link. /// - Returns: The `AuthDataResult` after a successful signin. - @available(iOS 13, tvOS 13, macOS 10.15, macCatalyst 13, watchOS 7, *) open func signIn(withEmail email: String, link: String) async throws -> AuthDataResult { - return try await withCheckedThrowingContinuation { continuation in - self.signIn(withEmail: email, link: link) { result, error in - if let result { - continuation.resume(returning: result) - } else { - continuation.resume(throwing: error!) - } - } - } + let result = try await authWorker.signIn(withEmail: email, link: link) + try await authWorker.updateCurrentUser(result.user, byForce: false, savingToDisk: true) + return result } #if os(iOS) @@ -530,18 +400,15 @@ extension Auth: AuthInterop { open func signIn(with provider: FederatedAuthProvider, uiDelegate: AuthUIDelegate?, completion: ((AuthDataResult?, Error?) -> Void)?) { - kAuthGlobalWorkQueue.async { - Task { - let decoratedCallback = self.signInFlowAuthDataResultCallback(byDecorating: completion) - do { - let credential = try await provider.credential(with: uiDelegate) - let authData = try await self.internalSignInAndRetrieveData( - withCredential: credential, - isReauthentication: false - ) - decoratedCallback(authData, nil) - } catch { - decoratedCallback(nil, error) + Task { + do { + let result = try await signIn(with: provider, uiDelegate: uiDelegate) + await MainActor.run { + completion?(result, nil) + } + } catch { + await MainActor.run { + completion?(nil, error) } } } @@ -577,22 +444,15 @@ extension Auth: AuthInterop { /// protocol, this is used for presenting the web context. If nil, a default AuthUIDelegate /// will be used. /// - Returns: The `AuthDataResult` after the successful signin. - @available(iOS 13, tvOS 13, macOS 10.15, macCatalyst 13, watchOS 7, *) @available(tvOS, unavailable) @available(macOS, unavailable) @available(watchOS, unavailable) @discardableResult open func signIn(with provider: FederatedAuthProvider, uiDelegate: AuthUIDelegate?) async throws -> AuthDataResult { - return try await withCheckedThrowingContinuation { continuation in - self.signIn(with: provider, uiDelegate: uiDelegate) { result, error in - if let result { - continuation.resume(returning: result) - } else { - continuation.resume(throwing: error!) - } - } - } + let result = try await authWorker.signIn(with: provider, uiDelegate: uiDelegate) + try await authWorker.updateCurrentUser(result.user, byForce: false, savingToDisk: true) + return result } #endif // iOS @@ -631,15 +491,15 @@ extension Auth: AuthInterop { @objc(signInWithCredential:completion:) open func signIn(with credential: AuthCredential, completion: ((AuthDataResult?, Error?) -> Void)? = nil) { - kAuthGlobalWorkQueue.async { - let decoratedCallback = self.signInFlowAuthDataResultCallback(byDecorating: completion) - Task { - do { - let authData = try await self.internalSignInAndRetrieveData(withCredential: credential, - isReauthentication: false) - decoratedCallback(authData, nil) - } catch { - decoratedCallback(nil, error) + Task { + do { + let result = try await signIn(with: credential) + await MainActor.run { + completion?(result, nil) + } + } catch { + await MainActor.run { + completion?(nil, error) } } } @@ -676,18 +536,11 @@ extension Auth: AuthInterop { /// * `AuthErrorCodeSessionExpired` - Indicates that the SMS code has expired. /// - Parameter credential: The credential supplied by the IdP. /// - Returns: The `AuthDataResult` after the successful signin. - @available(iOS 13, tvOS 13, macOS 10.15, macCatalyst 13, watchOS 7, *) @discardableResult open func signIn(with credential: AuthCredential) async throws -> AuthDataResult { - return try await withCheckedThrowingContinuation { continuation in - self.signIn(with: credential) { result, error in - if let result { - continuation.resume(returning: result) - } else { - continuation.resume(throwing: error!) - } - } - } + let result = try await authWorker.signIn(with: credential) + try await authWorker.updateCurrentUser(result.user, byForce: false, savingToDisk: true) + return result } /// Asynchronously creates and becomes an anonymous user. @@ -701,32 +554,15 @@ extension Auth: AuthInterop { /// - Parameter completion: Optionally; a block which is invoked when the sign in finishes, or is /// canceled. Invoked asynchronously on the main thread in the future. @objc open func signInAnonymously(completion: ((AuthDataResult?, Error?) -> Void)? = nil) { - kAuthGlobalWorkQueue.async { - let decoratedCallback = self.signInFlowAuthDataResultCallback(byDecorating: completion) - if let currentUser = self.currentUser, currentUser.isAnonymous { - let result = AuthDataResult(withUser: currentUser, additionalUserInfo: nil) - decoratedCallback(result, nil) - return - } - let request = SignUpNewUserRequest(requestConfiguration: self.requestConfiguration) - Task { - do { - let response = try await AuthBackend.call(with: request) - let user = try await self.completeSignIn( - withAccessToken: response.idToken, - accessTokenExpirationDate: response.approximateExpirationDate, - refreshToken: response.refreshToken, - anonymous: true - ) - // TODO: The ObjC implementation passed a nil providerID to the nonnull providerID - let additionalUserInfo = AdditionalUserInfo(providerID: "", - profile: nil, - username: nil, - isNewUser: true) - decoratedCallback(AuthDataResult(withUser: user, additionalUserInfo: additionalUserInfo), - nil) - } catch { - decoratedCallback(nil, error) + Task { + do { + let result = try await signInAnonymously() + await MainActor.run { + completion?(result, nil) + } + } catch { + await MainActor.run { + completion?(nil, error) } } } @@ -741,18 +577,11 @@ extension Auth: AuthInterop { /// * `AuthErrorCodeOperationNotAllowed` - Indicates that anonymous accounts are /// not enabled. Enable them in the Auth section of the Firebase console. /// - Returns: The `AuthDataResult` after the successful signin. - @available(iOS 13, tvOS 13, macOS 10.15, macCatalyst 13, watchOS 7, *) @discardableResult @objc open func signInAnonymously() async throws -> AuthDataResult { - return try await withCheckedThrowingContinuation { continuation in - self.signInAnonymously { result, error in - if let result { - continuation.resume(returning: result) - } else { - continuation.resume(throwing: error!) - } - } - } + let result = try await authWorker.signInAnonymously() + try await authWorker.updateCurrentUser(result.user, byForce: false, savingToDisk: true) + return result } /// Asynchronously signs in to Firebase with the given Auth token. @@ -767,28 +596,15 @@ extension Auth: AuthInterop { /// canceled. Invoked asynchronously on the main thread in the future. @objc open func signIn(withCustomToken token: String, completion: ((AuthDataResult?, Error?) -> Void)? = nil) { - kAuthGlobalWorkQueue.async { - let decoratedCallback = self.signInFlowAuthDataResultCallback(byDecorating: completion) - let request = VerifyCustomTokenRequest(token: token, - requestConfiguration: self.requestConfiguration) - Task { - do { - let response = try await AuthBackend.call(with: request) - let user = try await self.completeSignIn( - withAccessToken: response.idToken, - accessTokenExpirationDate: response.approximateExpirationDate, - refreshToken: response.refreshToken, - anonymous: false - ) - // TODO: The ObjC implementation passed a nil providerID to the nonnull providerID - let additionalUserInfo = AdditionalUserInfo(providerID: "", - profile: nil, - username: nil, - isNewUser: response.isNewUser) - decoratedCallback(AuthDataResult(withUser: user, additionalUserInfo: additionalUserInfo), - nil) - } catch { - decoratedCallback(nil, error) + Task { + do { + let result = try await signIn(withCustomToken: token) + await MainActor.run { + completion?(result, nil) + } + } catch { + await MainActor.run { + completion?(nil, error) } } } @@ -803,18 +619,11 @@ extension Auth: AuthInterop { /// belong to different projects. /// - Parameter token: A self-signed custom auth token. /// - Returns: The `AuthDataResult` after the successful signin. - @available(iOS 13, tvOS 13, macOS 10.15, macCatalyst 13, watchOS 7, *) @discardableResult open func signIn(withCustomToken token: String) async throws -> AuthDataResult { - return try await withCheckedThrowingContinuation { continuation in - self.signIn(withCustomToken: token) { result, error in - if let result { - continuation.resume(returning: result) - } else { - continuation.resume(throwing: error!) - } - } - } + let result = try await authWorker.signIn(withCustomToken: token) + try await authWorker.updateCurrentUser(result.user, byForce: false, savingToDisk: true) + return result } /// Creates and, on success, signs in a user with the given email address and password. @@ -836,70 +645,16 @@ extension Auth: AuthInterop { @objc open func createUser(withEmail email: String, password: String, completion: ((AuthDataResult?, Error?) -> Void)? = nil) { - guard password.count > 0 else { - if let completion { - completion(nil, AuthErrorUtils.weakPasswordError(serverResponseReason: "Missing password")) - } - return - } - guard email.count > 0 else { - if let completion { - completion(nil, AuthErrorUtils.missingEmailError(message: nil)) - } - return - } - kAuthGlobalWorkQueue.async { - let decoratedCallback = self.signInFlowAuthDataResultCallback(byDecorating: completion) - let request = SignUpNewUserRequest(email: email, - password: password, - displayName: nil, - idToken: nil, - requestConfiguration: self.requestConfiguration) - - #if os(iOS) - self.wrapInjectRecaptcha(request: request, - action: AuthRecaptchaAction.signUpPassword) { response, error in - if let error { - DispatchQueue.main.async { - decoratedCallback(nil, error) - } - return - } - self.internalCreateUserWithEmail(request: request, inResponse: response, - decoratedCallback: decoratedCallback) - } - #else - self.internalCreateUserWithEmail(request: request, decoratedCallback: decoratedCallback) - #endif - } - } - - func internalCreateUserWithEmail(request: SignUpNewUserRequest, - inResponse: SignUpNewUserResponse? = nil, - decoratedCallback: @escaping (AuthDataResult?, Error?) -> Void) { Task { do { - var response: SignUpNewUserResponse - if let inResponse { - response = inResponse - } else { - response = try await AuthBackend.call(with: request) + let result = try await createUser(withEmail: email, password: password) + await MainActor.run { + completion?(result, nil) } - let user = try await self.completeSignIn( - withAccessToken: response.idToken, - accessTokenExpirationDate: response.approximateExpirationDate, - refreshToken: response.refreshToken, - anonymous: false - ) - let additionalUserInfo = AdditionalUserInfo(providerID: EmailAuthProvider.id, - profile: nil, - username: nil, - isNewUser: true) - decoratedCallback(AuthDataResult(withUser: user, - additionalUserInfo: additionalUserInfo), - nil) } catch { - decoratedCallback(nil, error) + await MainActor.run { + completion?(nil, error) + } } } } @@ -919,18 +674,17 @@ extension Auth: AuthInterop { /// - Parameter email: The user's email address. /// - Parameter password: The user's desired password. /// - Returns: The `AuthDataResult` after the successful signin. - @available(iOS 13, tvOS 13, macOS 10.15, macCatalyst 13, watchOS 7, *) @discardableResult open func createUser(withEmail email: String, password: String) async throws -> AuthDataResult { - return try await withCheckedThrowingContinuation { continuation in - self.createUser(withEmail: email, password: password) { result, error in - if let result { - continuation.resume(returning: result) - } else { - continuation.resume(throwing: error!) - } - } + guard password.count > 0 else { + throw AuthErrorUtils.weakPasswordError(serverResponseReason: "Missing password") + } + guard email.count > 0 else { + throw AuthErrorUtils.missingEmailError(message: nil) } + let result = try await authWorker.createUser(withEmail: email, password: password) + try await authWorker.updateCurrentUser(result.user, byForce: false, savingToDisk: true) + return result } /// Resets the password given a code sent to the user outside of the app and a new password @@ -949,11 +703,17 @@ extension Auth: AuthInterop { /// Invoked asynchronously on the main thread in the future. @objc open func confirmPasswordReset(withCode code: String, newPassword: String, completion: @escaping (Error?) -> Void) { - kAuthGlobalWorkQueue.async { - let request = ResetPasswordRequest(oobCode: code, - newPassword: newPassword, - requestConfiguration: self.requestConfiguration) - self.wrapAsyncRPCTask(request, completion) + Task { + do { + try await confirmPasswordReset(withCode: code, newPassword: newPassword) + await MainActor.run { + completion(nil) + } + } catch { + await MainActor.run { + completion(error) + } + } } } @@ -969,17 +729,8 @@ extension Auth: AuthInterop { /// * `AuthErrorCodeInvalidActionCode` - Indicates the OOB code is invalid. /// - Parameter code: The reset code. /// - Parameter newPassword: The new password. - @available(iOS 13, tvOS 13, macOS 10.15, macCatalyst 13, watchOS 7, *) open func confirmPasswordReset(withCode code: String, newPassword: String) async throws { - return try await withCheckedThrowingContinuation { continuation in - self.confirmPasswordReset(withCode: code, newPassword: newPassword) { error in - if let error { - continuation.resume(throwing: error) - } else { - continuation.resume() - } - } - } + try await authWorker.confirmPasswordReset(withCode: code, newPassword: newPassword) } /// Checks the validity of an out of band code. @@ -989,24 +740,15 @@ extension Auth: AuthInterop { /// asynchronously on the main thread in the future. @objc open func checkActionCode(_ code: String, completion: @escaping (ActionCodeInfo?, Error?) -> Void) { - kAuthGlobalWorkQueue.async { - let request = ResetPasswordRequest(oobCode: code, - newPassword: nil, - requestConfiguration: self.requestConfiguration) - Task { - do { - let response = try await AuthBackend.call(with: request) - - let operation = ActionCodeInfo.actionCodeOperation(forRequestType: response.requestType) - guard let email = response.email else { - fatalError("Internal Auth Error: Failed to get a ResetPasswordResponse") - } - let actionCodeInfo = ActionCodeInfo(withOperation: operation, - email: email, - newEmail: response.verifiedEmail) - Auth.wrapMainAsync(callback: completion, withParam: actionCodeInfo, error: nil) - } catch { - Auth.wrapMainAsync(callback: completion, withParam: nil, error: error) + Task { + do { + let code = try await checkActionCode(code) + await MainActor.run { + completion(code, nil) + } + } catch { + await MainActor.run { + completion(nil, error) } } } @@ -1015,17 +757,8 @@ extension Auth: AuthInterop { /// Checks the validity of an out of band code. /// - Parameter code: The out of band code to check validity. /// - Returns: An `ActionCodeInfo`. - @available(iOS 13, tvOS 13, macOS 10.15, macCatalyst 13, watchOS 7, *) open func checkActionCode(_ code: String) async throws -> ActionCodeInfo { - return try await withCheckedThrowingContinuation { continuation in - self.checkActionCode(code) { info, error in - if let info { - continuation.resume(returning: info) - } else { - continuation.resume(throwing: error!) - } - } - } + return try await authWorker.checkActionCode(code) } /// Checks the validity of a verify password reset code. @@ -1034,29 +767,25 @@ extension Auth: AuthInterop { /// Invoked asynchronously on the main thread in the future. @objc open func verifyPasswordResetCode(_ code: String, completion: @escaping (String?, Error?) -> Void) { - checkActionCode(code) { info, error in - if let error { - completion(nil, error) - return + Task { + do { + let email = try await verifyPasswordResetCode(code) + await MainActor.run { + completion(email, nil) + } + } catch { + await MainActor.run { + completion(nil, error) + } } - completion(info?.email, nil) } } /// Checks the validity of a verify password reset code. /// - Parameter code: The password reset code to be verified. /// - Returns: An email. - @available(iOS 13, tvOS 13, macOS 10.15, macCatalyst 13, watchOS 7, *) open func verifyPasswordResetCode(_ code: String) async throws -> String { - return try await withCheckedThrowingContinuation { continuation in - self.verifyPasswordResetCode(code) { code, error in - if let code { - continuation.resume(returning: code) - } else { - continuation.resume(throwing: error!) - } - } - } + return try await authWorker.verifyPasswordResetCode(code) } /// Applies out of band code. @@ -1067,10 +796,17 @@ extension Auth: AuthInterop { /// - Parameter completion: Optionally; a block which is invoked when the request finishes. /// Invoked asynchronously on the main thread in the future. @objc open func applyActionCode(_ code: String, completion: @escaping (Error?) -> Void) { - kAuthGlobalWorkQueue.async { - let request = SetAccountInfoRequest(requestConfiguration: self.requestConfiguration) - request.oobCode = code - self.wrapAsyncRPCTask(request, completion) + Task { + do { + try await applyActionCode(code) + await MainActor.run { + completion(nil) + } + } catch { + await MainActor.run { + completion(error) + } + } } } @@ -1079,17 +815,8 @@ extension Auth: AuthInterop { /// This method will not work for out of band codes which require an additional parameter, /// such as password reset code. /// - Parameter code: The out of band code to be applied. - @available(iOS 13, tvOS 13, macOS 10.15, macCatalyst 13, watchOS 7, *) open func applyActionCode(_ code: String) async throws { - return try await withCheckedThrowingContinuation { continuation in - self.applyActionCode(code) { error in - if let error { - continuation.resume(throwing: error) - } else { - continuation.resume() - } - } - } + try await authWorker.applyActionCode(code) } /// Initiates a password reset for the given email address. @@ -1145,24 +872,17 @@ extension Auth: AuthInterop { @objc open func sendPasswordReset(withEmail email: String, actionCodeSettings: ActionCodeSettings?, completion: ((Error?) -> Void)? = nil) { - kAuthGlobalWorkQueue.async { - let request = GetOOBConfirmationCodeRequest.passwordResetRequest( - email: email, - actionCodeSettings: actionCodeSettings, - requestConfiguration: self.requestConfiguration - ) - #if os(iOS) - self.wrapInjectRecaptcha(request: request, - action: AuthRecaptchaAction.getOobCode) { result, error in - if let completion { - DispatchQueue.main.async { - completion(error) - } - } + Task { + do { + try await sendPasswordReset(withEmail: email, actionCodeSettings: actionCodeSettings) + await MainActor.run { + completion?(nil) } - #else - self.wrapAsyncRPCTask(request, completion) - #endif + } catch { + await MainActor.run { + completion?(error) + } + } } } @@ -1191,18 +911,9 @@ extension Auth: AuthInterop { /// - Parameter email: The email address of the user. /// - Parameter actionCodeSettings: An `ActionCodeSettings` object containing settings related to /// handling action codes. - @available(iOS 13, tvOS 13, macOS 10.15, macCatalyst 13, watchOS 7, *) open func sendPasswordReset(withEmail email: String, actionCodeSettings: ActionCodeSettings? = nil) async throws { - return try await withCheckedThrowingContinuation { continuation in - self.sendPasswordReset(withEmail: email, actionCodeSettings: actionCodeSettings) { error in - if let error { - continuation.resume(throwing: error) - } else { - continuation.resume() - } - } - } + try await authWorker.sendPasswordReset(withEmail: email, actionCodeSettings: actionCodeSettings) } /// Sends a sign in with email link to provided email address. @@ -1214,28 +925,17 @@ extension Auth: AuthInterop { @objc open func sendSignInLink(toEmail email: String, actionCodeSettings: ActionCodeSettings, completion: ((Error?) -> Void)? = nil) { - if !actionCodeSettings.handleCodeInApp { - fatalError("The handleCodeInApp flag in ActionCodeSettings must be true for Email-link " + - "Authentication.") - } - kAuthGlobalWorkQueue.async { - let request = GetOOBConfirmationCodeRequest.signInWithEmailLinkRequest( - email, - actionCodeSettings: actionCodeSettings, - requestConfiguration: self.requestConfiguration - ) - #if os(iOS) - self.wrapInjectRecaptcha(request: request, - action: AuthRecaptchaAction.getOobCode) { result, error in - if let completion { - DispatchQueue.main.async { - completion(error) - } - } + Task { + do { + try await sendSignInLink(toEmail: email, actionCodeSettings: actionCodeSettings) + await MainActor.run { + completion?(nil) } - #else - self.wrapAsyncRPCTask(request, completion) - #endif + } catch { + await MainActor.run { + completion?(error) + } + } } } @@ -1243,18 +943,13 @@ extension Auth: AuthInterop { /// - Parameter email: The email address of the user. /// - Parameter actionCodeSettings: An `ActionCodeSettings` object containing settings related to /// handling action codes. - @available(iOS 13, tvOS 13, macOS 10.15, macCatalyst 13, watchOS 7, *) open func sendSignInLink(toEmail email: String, actionCodeSettings: ActionCodeSettings) async throws { - return try await withCheckedThrowingContinuation { continuation in - self.sendSignInLink(toEmail: email, actionCodeSettings: actionCodeSettings) { error in - if let error { - continuation.resume(throwing: error) - } else { - continuation.resume() - } - } + if !actionCodeSettings.handleCodeInApp { + fatalError("The handleCodeInApp flag in ActionCodeSettings must be true for Email-link " + + "Authentication.") } + try await authWorker.sendSignInLink(toEmail: email, actionCodeSettings: actionCodeSettings) } /// Signs out the current user. @@ -1263,22 +958,33 @@ extension Auth: AuthInterop { /// * `AuthErrorCodeKeychainError` - Indicates an error occurred when accessing the /// keychain. The `NSLocalizedFailureReasonErrorKey` field in the `userInfo` /// dictionary will contain more information about the error encountered. + @available(*, noasync, message: "Use the async version instead") @objc(signOut:) open func signOut() throws { - try kAuthGlobalWorkQueue.sync { - guard self.currentUser != nil else { - return - } - return try self.updateCurrentUser(nil, byForce: false, savingToDisk: true) + let semaphore = DispatchSemaphore(value: 0) + Task { + try await authWorker.signOut() + semaphore.signal() } + semaphore.wait() } - /// Checks if link is an email sign-in link. - /// - Parameter link: The email sign-in link. - /// - Returns: `true` when the link passed matches the expected format of an email sign-in link. - @objc open func isSignIn(withEmailLink link: String) -> Bool { - guard link.count > 0 else { - return false - } + /// Signs out the current user. + /// + /// Possible error codes: + /// * `AuthErrorCodeKeychainError` - Indicates an error occurred when accessing the + /// keychain. The `NSLocalizedFailureReasonErrorKey` field in the `userInfo` + /// dictionary will contain more information about the error encountered. + open func signOut() async throws { + try await authWorker.signOut() + } + + /// Checks if link is an email sign-in link. + /// - Parameter link: The email sign-in link. + /// - Returns: `true` when the link passed matches the expected format of an email sign-in link. + @objc open func isSignIn(withEmailLink link: String) -> Bool { + guard !link.isEmpty else { + return false + } let queryItems = getQueryItems(link) if let _ = queryItems["oobCode"], let mode = queryItems["mode"], @@ -1409,24 +1115,24 @@ extension Auth: AuthInterop { /// Sets `languageCode` to the app's current language. @objc open func useAppLanguage() { - kAuthGlobalWorkQueue.sync { - self.requestConfiguration.languageCode = Locale.preferredLanguages.first - } + setLanguageCode(Locale.preferredLanguages.first) } /// Configures Firebase Auth to connect to an emulated host instead of the remote backend. @objc open func useEmulator(withHost host: String, port: Int) { + let semaphore = DispatchSemaphore(value: 0) + Task { + await useEmulator(withHost: host, port: port) + semaphore.signal() + } + semaphore.wait() + } + + open func useEmulator(withHost host: String, port: Int) async { guard host.count > 0 else { fatalError("Cannot connect to empty host") } - // If host is an IPv6 address, it should be formatted with surrounding brackets. - let formattedHost = host.contains(":") ? "[\(host)]" : host - kAuthGlobalWorkQueue.sync { - self.requestConfiguration.emulatorHostAndPort = "\(formattedHost):\(port)" - #if os(iOS) - self.settings?.appVerificationDisabledForTesting = true - #endif - } + await authWorker.useEmulator(withHost: host, port: port) } /// Revoke the users token with authorization code. @@ -1434,18 +1140,17 @@ extension Auth: AuthInterop { /// complete, or fails. Invoked asynchronously on the main thread in the future. @objc open func revokeToken(withAuthorizationCode authorizationCode: String, completion: ((Error?) -> Void)? = nil) { - currentUser?.internalGetToken { idToken, error in - if let error { - Auth.wrapMainAsync(completion, error) - return - } - guard let idToken else { - fatalError("Internal Auth Error: Both idToken and error are nil") + Task { + do { + try await revokeToken(withAuthorizationCode: authorizationCode) + if let completion { + completion(nil) + } + } catch { + if let completion { + completion(error) + } } - let request = RevokeTokenRequest(withToken: authorizationCode, - idToken: idToken, - requestConfiguration: self.requestConfiguration) - self.wrapAsyncRPCTask(request, completion) } } @@ -1454,14 +1159,12 @@ extension Auth: AuthInterop { /// complete, or fails. Invoked asynchronously on the main thread in the future. @available(iOS 13, tvOS 13, macOS 10.15, macCatalyst 13, watchOS 7, *) open func revokeToken(withAuthorizationCode authorizationCode: String) async throws { - return try await withCheckedThrowingContinuation { continuation in - self.revokeToken(withAuthorizationCode: authorizationCode) { error in - if let error { - continuation.resume(throwing: error) - } else { - continuation.resume() - } - } + if let currentUser { + let idToken = try await currentUser.internalGetToken() + let request = RevokeTokenRequest(withToken: authorizationCode, + idToken: idToken, + requestConfiguration: requestConfiguration) + let _ = try await AuthBackend.call(with: request) } } @@ -1472,7 +1175,7 @@ extension Auth: AuthInterop { return try internalUseUserAccessGroup(accessGroup) } - private func internalUseUserAccessGroup(_ accessGroup: String?) throws { + func internalUseUserAccessGroup(_ accessGroup: String?) throws { storedUserManager.setStoredUserAccessGroup(accessGroup: accessGroup) let user = try getStoredUser(forAccessGroup: accessGroup) try updateCurrentUser(user, byForce: false, savingToDisk: false) @@ -1538,6 +1241,40 @@ extension Auth: AuthInterop { return user } + /// Sets the `currentUser` on the receiver to the provided user object. + /// - Parameters: + /// - user: The user object to be set as the current user of the calling Auth instance. + /// - completion: Optionally; a block invoked after the user of the calling Auth instance has + /// been updated or an error was encountered. + @objc open func updateCurrentUser(_ user: User?, completion: ((Error?) -> Void)? = nil) { + Task { + guard let user else { + await MainActor.run { + completion?(AuthErrorUtils.nullUserError(message: nil)) + } + return + } + do { + try await updateCurrentUser(user) + await MainActor.run { + completion?(nil) + } + } catch { + await MainActor.run { + completion?(error) + } + } + } + } + + /// Sets the `currentUser` on the receiver to the provided user object. + /// - Parameter user: The user object to be set as the current user of the calling Auth instance. + /// - Parameter completion: Optionally; a block invoked after the user of the calling Auth + /// instance has been updated or an error was encountered. + open func updateCurrentUser(_ user: User) async throws { + try await authWorker.updateCurrentUser(user) + } + #if os(iOS) /// The APNs token used for phone number authentication. /// @@ -1549,9 +1286,42 @@ extension Auth: AuthInterop { /// If swizzling is disabled, the APNs Token must be set for phone number auth to work, /// by either setting this property or by calling `setAPNSToken(_:type:)`. @objc(APNSToken) open var apnsToken: Data? { - kAuthGlobalWorkQueue.sync { - self.tokenManager.token?.data + var data: Data? + let semaphore = DispatchSemaphore(value: 0) + Task { + data = await tokenManagerGet().token?.data + semaphore.signal() + } + semaphore.wait() + return data + } + + func tokenManagerInit(_ manager: AuthAPNSTokenManager) { + let semaphore = DispatchSemaphore(value: 0) + Task { + await authWorker.tokenManagerInit(manager) + semaphore.signal() } + semaphore.wait() + } + + func tokenManagerInit(_ manager: AuthAPNSTokenManager) async { + await authWorker.tokenManagerInit(manager) + } + + func tokenManagerGet() -> AuthAPNSTokenManager { + var manager: AuthAPNSTokenManager! + let semaphore = DispatchSemaphore(value: 0) + Task { + manager = await tokenManagerGet() + semaphore.signal() + } + semaphore.wait() + return manager + } + + func tokenManagerGet() async -> AuthAPNSTokenManager { + return await authWorker.tokenManagerGet() } /// Sets the APNs token along with its type. @@ -1561,9 +1331,22 @@ extension Auth: AuthInterop { /// If swizzling is disabled, the APNs Token must be set for phone number auth to work, /// by either setting calling this method or by setting the `APNSToken` property. @objc open func setAPNSToken(_ token: Data, type: AuthAPNSTokenType) { - kAuthGlobalWorkQueue.sync { - self.tokenManager.token = AuthAPNSToken(withData: token, type: type) + let semaphore = DispatchSemaphore(value: 0) + Task { + await authWorker.tokenManagerSet(token, type: type) + semaphore.signal() } + semaphore.wait() + } + + /// Sets the APNs token along with its type. + /// + /// This method is available on iOS only. + /// + /// If swizzling is disabled, the APNs Token must be set for phone number auth to work, + /// by either setting calling this method or by setting the `APNSToken` property. + open func setAPNSToken(_ token: Data, type: AuthAPNSTokenType) async { + await authWorker.tokenManagerSet(token, type: type) } /// Whether the specific remote notification is handled by `Auth` . @@ -1579,9 +1362,30 @@ extension Auth: AuthInterop { /// processing, and `false` means the notification is for the app (or another library) so /// the caller should continue handling this notification as usual. @objc open func canHandleNotification(_ userInfo: [AnyHashable: Any]) -> Bool { - kAuthGlobalWorkQueue.sync { - self.notificationManager.canHandle(notification: userInfo) + var result = false + let semaphore = DispatchSemaphore(value: 0) + Task { + result = await authWorker.canHandleNotification(userInfo) + semaphore.signal() } + semaphore.wait() + return result + } + + /// Whether the specific remote notification is handled by `Auth` . + /// + /// This method is available on iOS only. + /// + /// If swizzling is disabled, related remote notifications must be forwarded to this method + /// for phone number auth to work. + /// - Parameter userInfo: A dictionary that contains information related to the + /// notification in question. + /// - Returns: Whether or the notification is handled. A return value of `true` means the + /// notification is for Firebase Auth so the caller should ignore the notification from further + /// processing, and `false` means the notification is for the app (or another library) so + /// the caller should continue handling this notification as usual. + @objc open func canHandleNotification(_ userInfo: [AnyHashable: Any]) async -> Bool { + return await authWorker.canHandleNotification(userInfo) } /// Whether the specific URL is handled by `Auth` . @@ -1597,12 +1401,30 @@ extension Auth: AuthInterop { /// the URL is for the app (or another library) so the caller should continue handling /// this URL as usual. @objc(canHandleURL:) open func canHandle(_ url: URL) -> Bool { - kAuthGlobalWorkQueue.sync { - guard let authURLPresenter = self.authURLPresenter as? AuthURLPresenter else { - return false - } - return authURLPresenter.canHandle(url: url) + var result = false + let semaphore = DispatchSemaphore(value: 0) + Task { + result = await authWorker.canHandle(url) + semaphore.signal() } + semaphore.wait() + return result + } + + /// Whether the specific URL is handled by `Auth` . + /// + /// This method is available on iOS only. + /// + /// If swizzling is disabled, URLs received by the application delegate must be forwarded + /// to this method for phone number auth to work. + /// - Parameter url: The URL received by the application delegate from any of the openURL + /// method. + /// - Returns: Whether or the URL is handled. `true` means the URL is for Firebase Auth + /// so the caller should ignore the URL from further processing, and `false` means the + /// the URL is for the app (or another library) so the caller should continue handling + /// this URL as usual. + open func canHandle(_ url: URL) async -> Bool { + return await authWorker.canHandle(url) } #endif @@ -1640,83 +1462,69 @@ extension Auth: AuthInterop { auth: nil, heartbeatLogger: app.heartbeatLogger, appCheck: appCheck) + authWorker = AuthWorker(requestConfiguration: requestConfiguration) super.init() requestConfiguration.auth = self - protectedDataInitialization(keychainStorageProvider) + Task { + await authWorker.protectedDataInitialization(keychainStorageProvider) + } } - private func protectedDataInitialization(_ keychainStorageProvider: AuthKeychainStorage) { - // Continue with the rest of initialization in the work thread. - kAuthGlobalWorkQueue.async { [weak self] in - // Load current user from Keychain. - guard let self else { + // TODO: delete me + + func signInFlowAuthDataResultCallback(byDecorating callback: + ((AuthDataResult?, Error?) -> Void)?) -> (AuthDataResult?, Error?) -> Void { + let authDataCallback: (((AuthDataResult?, Error?) -> Void)?, AuthDataResult?, Error?) -> Void = + { callback, result, error in + Auth.wrapMainAsync(callback: callback, withParam: result, error: error) + } + return { authResult, error in + if let error { + authDataCallback(callback, nil, error) return } - if let keychainServiceName = Auth.keychainServiceName(forAppName: self.firebaseAppName) { - self.keychainServices = AuthKeychainServices(service: keychainServiceName, - storage: keychainStorageProvider) - self.storedUserManager = AuthStoredUserManager( - serviceName: keychainServiceName, - keychainServices: self.keychainServices - ) + do { + try self.updateCurrentUser(authResult?.user, byForce: false, savingToDisk: true) + } catch { + authDataCallback(callback, nil, error) + return } + authDataCallback(callback, authResult, nil) + } + } + // TODO: delete me + func updateCurrentUser(_ user: User?, byForce force: Bool, + savingToDisk saveToDisk: Bool) throws { + if user == currentUser { + possiblyPostAuthStateChangeNotification() + } + if let user { + if user.tenantID != nil || tenantID != nil, tenantID != user.tenantID { + let error = AuthErrorUtils.tenantIDMismatchError() + throw error + } + } + var throwError: Error? + if saveToDisk { do { - if let storedUserAccessGroup = self.storedUserManager.getStoredUserAccessGroup() { - try self.internalUseUserAccessGroup(storedUserAccessGroup) - } else { - let user = try self.getUser() - try self.updateCurrentUser(user, byForce: false, savingToDisk: false) - if let user { - self.tenantID = user.tenantID - self.lastNotifiedUserToken = user.rawAccessToken() - } - } + try saveUser(user) } catch { - #if os(iOS) || os(tvOS) || targetEnvironment(macCatalyst) - if (error as NSError).code == AuthErrorCode.keychainError.rawValue { - // If there's a keychain error, assume it is due to the keychain being accessed - // before the device is unlocked as a result of prewarming, and listen for the - // UIApplicationProtectedDataDidBecomeAvailable notification. - self.addProtectedDataDidBecomeAvailableObserver() - } - #endif - AuthLog.logError(code: "I-AUT000001", - message: "Error loading saved user when starting up: \(error)") + throwError = error } - - #if os(iOS) - if GULAppEnvironmentUtil.isAppExtension() { - // iOS App extensions should not call [UIApplication sharedApplication], even if - // UIApplication responds to it. - return - } - - // Using reflection here to avoid build errors in extensions. - let sel = NSSelectorFromString("sharedApplication") - guard UIApplication.responds(to: sel), - let rawApplication = UIApplication.perform(sel), - let application = rawApplication.takeUnretainedValue() as? UIApplication else { - return - } - - // Initialize for phone number auth. - self.tokenManager = AuthAPNSTokenManager(withApplication: application) - self.appCredentialManager = AuthAppCredentialManager(withKeychain: self.keychainServices) - self.notificationManager = AuthNotificationManager( - withApplication: application, - appCredentialManager: self.appCredentialManager - ) - - GULAppDelegateSwizzler.registerAppDelegateInterceptor(self) - GULSceneDelegateSwizzler.registerSceneDelegateInterceptor(self) - #endif + } + if throwError == nil || force { + currentUser = user + possiblyPostAuthStateChangeNotification() + } + if let throwError { + throw throwError } } #if os(iOS) || os(tvOS) || targetEnvironment(macCatalyst) - private func addProtectedDataDidBecomeAvailableObserver() { + func addProtectedDataDidBecomeAvailableObserver() { weak var weakSelf = self protectedDataDidBecomeAvailableObserver = NotificationCenter.default.addObserver( @@ -1754,7 +1562,7 @@ extension Auth: AuthInterop { #endif } - private func getUser() throws -> User? { + func getUser() throws -> User? { var user: User? if let userAccessGroup { guard let apiKey = app?.options.apiKey else { @@ -1839,7 +1647,7 @@ extension Auth: AuthInterop { // MARK: Private methods /// Posts the auth state change notification if current user's token has been changed. - private func possiblyPostAuthStateChangeNotification() { + func possiblyPostAuthStateChangeNotification() { let token = currentUser?.rawAccessToken() if lastNotifiedUserToken == token || (token != nil && lastNotifiedUserToken == token) { @@ -1872,7 +1680,7 @@ extension Auth: AuthInterop { /// is scheduled 5 minutes before the scheduled expiration time. /// /// If the token expires in less than 5 minutes, schedule the token refresh immediately. - private func scheduleAutoTokenRefresh() { + func scheduleAutoTokenRefresh() { let tokenExpirationInterval = (currentUser?.accessTokenExpirationDate()?.timeIntervalSinceNow ?? 0) - 5 * 60 scheduleAutoTokenRefresh(withDelay: max(tokenExpirationInterval, 0), retry: false) @@ -1882,7 +1690,7 @@ extension Auth: AuthInterop { /// - Parameter delay: The delay in seconds after which the token refresh task should be scheduled /// to be executed. /// - Parameter retry: Flag to determine whether the invocation is a retry attempt or not. - private func scheduleAutoTokenRefresh(withDelay delay: TimeInterval, retry: Bool) { + func scheduleAutoTokenRefresh(withDelay delay: TimeInterval, retry: Bool) { guard let accessToken = currentUser?.rawAccessToken() else { return } @@ -1897,72 +1705,14 @@ extension Auth: AuthInterop { "for the new token.") } autoRefreshScheduled = true - weak var weakSelf = self - AuthDispatcher.shared.dispatch(afterDelay: delay, queue: kAuthGlobalWorkQueue) { - guard let strongSelf = weakSelf else { - return - } - guard strongSelf.currentUser?.rawAccessToken() == accessToken else { - // Another auto refresh must have been scheduled, so keep _autoRefreshScheduled unchanged. - return - } - strongSelf.autoRefreshScheduled = false - if strongSelf.isAppInBackground { - return - } - let uid = strongSelf.currentUser?.uid - strongSelf.currentUser?.internalGetToken(forceRefresh: true) { token, error in - if strongSelf.currentUser?.uid != uid { - return - } - if error != nil { - // Kicks off exponential back off logic to retry failed attempt. Starts with one minute - // delay (60 seconds) if this is the first failed attempt. - let rescheduleDelay = retry ? min(delay * 2, 16 * 60) : 60 - strongSelf.scheduleAutoTokenRefresh(withDelay: rescheduleDelay, retry: true) - } - } - } - } - - /// Update the current user; initializing the user's internal properties correctly, and - /// optionally saving the user to disk. - /// - /// This method is called during: sign in and sign out events, as well as during class - /// initialization time. The only time the saveToDisk parameter should be set to NO is during - /// class initialization time because the user was just read from disk. - /// - Parameter user: The user to use as the current user (including nil, which is passed at sign - /// out time.) - /// - Parameter saveToDisk: Indicates the method should persist the user data to disk. - func updateCurrentUser(_ user: User?, byForce force: Bool, - savingToDisk saveToDisk: Bool) throws { - if user == currentUser { - possiblyPostAuthStateChangeNotification() - } - if let user { - if user.tenantID != nil || tenantID != nil, tenantID != user.tenantID { - let error = AuthErrorUtils.tenantIDMismatchError() - throw error - } - } - var throwError: Error? - if saveToDisk { - do { - try saveUser(user) - } catch { - throwError = error - } - } - if throwError == nil || force { - currentUser = user - possiblyPostAuthStateChangeNotification() - } - if let throwError { - throw throwError + Task { + await authWorker.autoTokenRefresh(accessToken: accessToken, + retry: retry, + delay: fastTokenRefreshForTest ? 0.1 : delay) } } - private func saveUser(_ user: User?) throws { + func saveUser(_ user: User?) throws { if let userAccessGroup { guard let apiKey = app?.options.apiKey else { fatalError("Internal Auth Error: Missing apiKey in saveUser") @@ -2011,194 +1761,6 @@ extension Auth: AuthInterop { anonymous: anonymous) } - /// Signs in using an email address and password. - /// - /// This is the internal counterpart of this method, which uses a callback that does not - /// update the current user. - /// - Parameter email: The user's email address. - /// - Parameter password: The user's password. - private func internalSignInAndRetrieveData(withEmail email: String, - password: String) async throws -> AuthDataResult { - let credential = EmailAuthCredential(withEmail: email, password: password) - return try await internalSignInAndRetrieveData(withCredential: credential, - isReauthentication: false) - } - - func internalSignInAndRetrieveData(withCredential credential: AuthCredential, - isReauthentication: Bool) async throws - -> AuthDataResult { - if let emailCredential = credential as? EmailAuthCredential { - // Special case for email/password credentials - switch emailCredential.emailType { - case let .link(link): - // Email link sign in - return try await internalSignInAndRetrieveData(withEmail: emailCredential.email, link: link) - case let .password(password): - // Email password sign in - let user = try await internalSignInUser( - withEmail: emailCredential.email, - password: password - ) - let additionalUserInfo = AdditionalUserInfo(providerID: EmailAuthProvider.id, - profile: nil, - username: nil, - isNewUser: false) - return AuthDataResult(withUser: user, additionalUserInfo: additionalUserInfo) - } - } - #if !os(watchOS) - if let gameCenterCredential = credential as? GameCenterAuthCredential { - return try await signInAndRetrieveData(withGameCenterCredential: gameCenterCredential) - } - #endif - #if os(iOS) - if let phoneCredential = credential as? PhoneAuthCredential { - // Special case for phone auth credentials - let operation = isReauthentication ? AuthOperationType.reauth : - AuthOperationType.signUpOrSignIn - let response = try await signIn(withPhoneCredential: phoneCredential, - operation: operation) - let user = try await completeSignIn(withAccessToken: response.idToken, - accessTokenExpirationDate: response - .approximateExpirationDate, - refreshToken: response.refreshToken, - anonymous: false) - - let additionalUserInfo = AdditionalUserInfo(providerID: PhoneAuthProvider.id, - profile: nil, - username: nil, - isNewUser: response.isNewUser) - return AuthDataResult(withUser: user, additionalUserInfo: additionalUserInfo) - } - #endif - - let request = VerifyAssertionRequest(providerID: credential.provider, - requestConfiguration: requestConfiguration) - request.autoCreate = !isReauthentication - credential.prepare(request) - let response = try await AuthBackend.call(with: request) - if response.needConfirmation { - let email = response.email - let credential = OAuthCredential(withVerifyAssertionResponse: response) - throw AuthErrorUtils.accountExistsWithDifferentCredentialError( - email: email, - updatedCredential: credential - ) - } - guard let providerID = response.providerID, providerID.count > 0 else { - throw AuthErrorUtils.unexpectedResponse(deserializedResponse: response) - } - let user = try await completeSignIn(withAccessToken: response.idToken, - accessTokenExpirationDate: response - .approximateExpirationDate, - refreshToken: response.refreshToken, - anonymous: false) - let additionalUserInfo = AdditionalUserInfo(providerID: providerID, - profile: response.profile, - username: response.username, - isNewUser: response.isNewUser) - let updatedOAuthCredential = OAuthCredential(withVerifyAssertionResponse: response) - return AuthDataResult(withUser: user, - additionalUserInfo: additionalUserInfo, - credential: updatedOAuthCredential) - } - - #if os(iOS) - /// Signs in using a phone credential. - /// - Parameter credential: The Phone Auth credential used to sign in. - /// - Parameter operation: The type of operation for which this sign-in attempt is initiated. - private func signIn(withPhoneCredential credential: PhoneAuthCredential, - operation: AuthOperationType) async throws -> VerifyPhoneNumberResponse { - switch credential.credentialKind { - case let .phoneNumber(phoneNumber, temporaryProof): - let request = VerifyPhoneNumberRequest(temporaryProof: temporaryProof, - phoneNumber: phoneNumber, - operation: operation, - requestConfiguration: requestConfiguration) - return try await AuthBackend.call(with: request) - case let .verification(verificationID, code): - guard verificationID.count > 0 else { - throw AuthErrorUtils.missingVerificationIDError(message: nil) - } - guard code.count > 0 else { - throw AuthErrorUtils.missingVerificationCodeError(message: nil) - } - let request = VerifyPhoneNumberRequest(verificationID: verificationID, - verificationCode: code, - operation: operation, - requestConfiguration: requestConfiguration) - return try await AuthBackend.call(with: request) - } - } - #endif - - #if !os(watchOS) - /// Signs in using a game center credential. - /// - Parameter credential: The Game Center Auth Credential used to sign in. - private func signInAndRetrieveData(withGameCenterCredential credential: GameCenterAuthCredential) async throws - -> AuthDataResult { - guard let publicKeyURL = credential.publicKeyURL, - let signature = credential.signature, - let salt = credential.salt else { - fatalError( - "Internal Auth Error: Game Center credential missing publicKeyURL, signature, or salt" - ) - } - let request = SignInWithGameCenterRequest(playerID: credential.playerID, - teamPlayerID: credential.teamPlayerID, - gamePlayerID: credential.gamePlayerID, - publicKeyURL: publicKeyURL, - signature: signature, - salt: salt, - timestamp: credential.timestamp, - displayName: credential.displayName, - requestConfiguration: requestConfiguration) - let response = try await AuthBackend.call(with: request) - let user = try await completeSignIn(withAccessToken: response.idToken, - accessTokenExpirationDate: response - .approximateExpirationDate, - refreshToken: response.refreshToken, - anonymous: false) - let additionalUserInfo = AdditionalUserInfo(providerID: GameCenterAuthProvider.id, - profile: nil, - username: nil, - isNewUser: response.isNewUser) - return AuthDataResult(withUser: user, additionalUserInfo: additionalUserInfo) - } - - #endif - - /// Signs in using an email and email sign-in link. - /// - Parameter email: The user's email address. - /// - Parameter link: The email sign-in link. - private func internalSignInAndRetrieveData(withEmail email: String, - link: String) async throws -> AuthDataResult { - guard isSignIn(withEmailLink: link) else { - fatalError("The link provided is not valid for email/link sign-in. Please check the link by " + - "calling isSignIn(withEmailLink:) on the Auth instance before attempting to use it " + - "for email/link sign-in.") - } - let queryItems = getQueryItems(link) - guard let actionCode = queryItems["oobCode"] else { - fatalError("Missing oobCode in link URL") - } - let request = EmailLinkSignInRequest(email: email, - oobCode: actionCode, - requestConfiguration: requestConfiguration) - let response = try await AuthBackend.call(with: request) - let user = try await completeSignIn(withAccessToken: response.idToken, - accessTokenExpirationDate: response - .approximateExpirationDate, - refreshToken: response.refreshToken, - anonymous: false) - - let additionalUserInfo = AdditionalUserInfo(providerID: EmailAuthProvider.id, - profile: nil, - username: nil, - isNewUser: response.isNewUser) - return AuthDataResult(withUser: user, additionalUserInfo: additionalUserInfo) - } - private func getQueryItems(_ link: String) -> [String: String] { var queryItems = AuthWebUtils.parseURL(link) if queryItems.count == 0 { @@ -2210,45 +1772,24 @@ extension Auth: AuthInterop { return queryItems } - /// Creates a AuthDataResultCallback block which wraps another AuthDataResultCallback; - /// trying to update the current user before forwarding it's invocations along to a subject - /// block. - /// - /// Typically invoked as part of the complete sign-in flow. For any other uses please - /// consider alternative ways of updating the current user. - /// - Parameter callback: Called when the user has been updated or when an error has occurred. - /// Invoked asynchronously on the main thread in the future. - /// - Returns: Returns a block that updates the current user. - func signInFlowAuthDataResultCallback(byDecorating callback: - ((AuthDataResult?, Error?) -> Void)?) -> (AuthDataResult?, Error?) -> Void { - let authDataCallback: (((AuthDataResult?, Error?) -> Void)?, AuthDataResult?, Error?) -> Void = - { callback, result, error in - Auth.wrapMainAsync(callback: callback, withParam: result, error: error) - } - return { authResult, error in - if let error { - authDataCallback(callback, nil, error) - return - } - do { - try self.updateCurrentUser(authResult?.user, byForce: false, savingToDisk: true) - } catch { - authDataCallback(callback, nil, error) - return - } - authDataCallback(callback, authResult, nil) + private func getLanguageCode() -> String? { + var code: String? + let semaphore = DispatchSemaphore(value: 0) + Task { + code = await authWorker.getLanguageCode() + semaphore.signal() } + semaphore.wait() + return code } - private func wrapAsyncRPCTask(_ request: any AuthRPCRequest, _ callback: ((Error?) -> Void)?) { + private func setLanguageCode(_ code: String?) { + let semaphore = DispatchSemaphore(value: 0) Task { - do { - let _ = try await AuthBackend.call(with: request) - Auth.wrapMainAsync(callback, nil) - } catch { - Auth.wrapMainAsync(callback, error) - } + await authWorker.setLanguageCode(code) + semaphore.signal() } + semaphore.wait() } class func wrapMainAsync(_ callback: ((Error?) -> Void)?, _ error: Error?) { @@ -2269,55 +1810,6 @@ extension Auth: AuthInterop { } } - #if os(iOS) - private func wrapInjectRecaptcha(request: T, - action: AuthRecaptchaAction, - _ callback: @escaping ( - (T.Response?, Error?) -> Void - )) { - Task { - do { - let response = try await injectRecaptcha(request: request, action: action) - callback(response, nil) - } catch { - callback(nil, error) - } - } - } - - func injectRecaptcha(request: T, - action: AuthRecaptchaAction) async throws -> T - .Response { - let recaptchaVerifier = AuthRecaptchaVerifier.shared(auth: self) - if recaptchaVerifier.enablementStatus(forProvider: AuthRecaptchaProvider.password) { - try await recaptchaVerifier.injectRecaptchaFields(request: request, - provider: AuthRecaptchaProvider.password, - action: action) - } else { - do { - return try await AuthBackend.call(with: request) - } catch { - let nsError = error as NSError - if let underlyingError = nsError.userInfo[NSUnderlyingErrorKey] as? NSError, - nsError.code == AuthErrorCode.internalError.rawValue, - let messages = underlyingError - .userInfo[AuthErrorUtils.userInfoDeserializedResponseKey] as? [String: AnyHashable], - let message = messages["message"] as? String, - message.hasPrefix("MISSING_RECAPTCHA_TOKEN") { - try await recaptchaVerifier.injectRecaptchaFields( - request: request, - provider: AuthRecaptchaProvider.password, - action: action - ) - } else { - throw error - } - } - } - return try await AuthBackend.call(with: request) - } - #endif - // MARK: Internal properties /// Allow tests to swap in an alternate mainBundle. @@ -2327,10 +1819,11 @@ extension Auth: AuthInterop { /// Auth's backend. var requestConfiguration: AuthRequestConfiguration - #if os(iOS) + let authWorker: AuthWorker - /// The manager for APNs tokens used by phone number auth. - var tokenManager: AuthAPNSTokenManager! + var fastTokenRefreshForTest = false + + #if os(iOS) /// The manager for app credentials used by phone number auth. var appCredentialManager: AuthAppCredentialManager! @@ -2346,16 +1839,16 @@ extension Auth: AuthInterop { // MARK: Private properties /// The stored user manager. - private var storedUserManager: AuthStoredUserManager! + var storedUserManager: AuthStoredUserManager! /// The Firebase app name. - private let firebaseAppName: String + let firebaseAppName: String /// The keychain service. - private var keychainServices: AuthKeychainServices! + var keychainServices: AuthKeychainServices! /// The user access (ID) token used last time for posting auth state changed notification. - private var lastNotifiedUserToken: String? + var lastNotifiedUserToken: String? /// This flag denotes whether or not tokens should be automatically refreshed. /// Will only be set to `true` if the another Firebase service is included (additionally to @@ -2363,11 +1856,11 @@ extension Auth: AuthInterop { private var autoRefreshTokens = false /// Whether or not token auto-refresh is currently scheduled. - private var autoRefreshScheduled = false + var autoRefreshScheduled = false /// A flag that is set to YES if the app is put in the background and no when the app is /// returned to the foreground. - private var isAppInBackground = false + var isAppInBackground = false /// An opaque object to act as the observer for UIApplicationDidBecomeActiveNotification. private var applicationDidBecomeActiveObserver: NSObjectProtocol? diff --git a/FirebaseAuth/Sources/Swift/Auth/AuthDispatcher.swift b/FirebaseAuth/Sources/Swift/Auth/AuthDispatcher.swift deleted file mode 100644 index 6373cdfb4cc..00000000000 --- a/FirebaseAuth/Sources/Swift/Auth/AuthDispatcher.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 - -/// A utility class used to facilitate scheduling tasks to be executed in the future. -class AuthDispatcher { - static let shared = AuthDispatcher() - - /// Allows custom implementation of dispatchAfterDelay:queue:callback:. - /// - /// Set to nil to restore default implementation. - var dispatchAfterImplementation: ((TimeInterval, DispatchQueue, @escaping () -> Void) -> Void)? - - /// Schedules task in the future after a specified delay. - /// - Parameter delay: The delay in seconds after which the task will be scheduled to execute. - /// - Parameter queue: The dispatch queue on which the task will be submitted. - /// - Parameter task: The task(block) to be scheduled for future execution. - func dispatch(afterDelay delay: TimeInterval, - queue: DispatchQueue, - task: @escaping () -> Void) { - if let dispatchAfterImplementation { - dispatchAfterImplementation(delay, queue, task) - } else { - queue.asyncAfter(deadline: DispatchTime.now() + delay, execute: task) - } - } -} diff --git a/FirebaseAuth/Sources/Swift/Auth/AuthWorker.swift b/FirebaseAuth/Sources/Swift/Auth/AuthWorker.swift new file mode 100644 index 00000000000..896c78e417a --- /dev/null +++ b/FirebaseAuth/Sources/Swift/Auth/AuthWorker.swift @@ -0,0 +1,1226 @@ +// 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 + +#if COCOAPODS + @_implementationOnly import GoogleUtilities +#else + @_implementationOnly import GoogleUtilities_AppDelegateSwizzler + @_implementationOnly import GoogleUtilities_Environment +#endif + +#if canImport(UIKit) + import UIKit +#endif + +@available(iOS 13, tvOS 13, macOS 10.15, macCatalyst 13, watchOS 7, *) +actor AuthWorker { + let requestConfiguration: AuthRequestConfiguration + + func getLanguageCode() -> String? { + return requestConfiguration.languageCode + } + + func setLanguageCode(_ code: String?) { + requestConfiguration.languageCode = code + } + + /// The manager for APNs tokens used by phone number auth. + var tokenManager: AuthAPNSTokenManager! + + func tokenManagerCancel(error: Error) { + tokenManager.cancel(withError: error) + } + + func tokenManagerSet(_ token: Data, type: AuthAPNSTokenType) { + tokenManager.token = AuthAPNSToken(withData: token, type: type) + } + + func tokenManagerGet() -> AuthAPNSTokenManager { + return tokenManager + } + + func getToken(forcingRefresh forceRefresh: Bool) async throws -> String? { + // Enable token auto-refresh if not already enabled. + guard let auth = requestConfiguration.auth else { + return nil + } + auth.getTokenInternal(forcingRefresh: forceRefresh) + + // Call back with 'nil' if there is no current user. + guard let currentUser = auth.currentUser else { + return nil + } + return try await currentUser.internalGetToken(forceRefresh: forceRefresh) + } + + /// Only for testing + func tokenManagerInit(_ manager: AuthAPNSTokenManager) { + tokenManager = manager + } + + func fetchSignInMethods(forEmail email: String) async throws -> [String] { + let request = CreateAuthURIRequest(identifier: email, + continueURI: "http:www.google.com", + requestConfiguration: requestConfiguration) + let response = try await AuthBackend.call(with: request) + return response.signinMethods ?? [] + } + + func signIn(withEmail email: String, password: String) async throws -> AuthDataResult { + let credential = EmailAuthCredential(withEmail: email, password: password) + return try await internalSignInAndRetrieveData(withCredential: credential, + isReauthentication: false) + } + + func signIn(withEmail email: String, link: String) async throws -> AuthDataResult { + let credential = EmailAuthCredential(withEmail: email, link: link) + return try await internalSignInAndRetrieveData(withCredential: credential, + isReauthentication: false) + } + + func signIn(with credential: AuthCredential) async throws -> AuthDataResult { + return try await internalSignInAndRetrieveData(withCredential: credential, + isReauthentication: false) + } + + #if os(iOS) + func signIn(with provider: FederatedAuthProvider, + uiDelegate: AuthUIDelegate?) async throws -> AuthDataResult { + let credential = try await provider.credential(with: uiDelegate) + return try await internalSignInAndRetrieveData( + withCredential: credential, + isReauthentication: false + ) + } + #endif + + func signInAnonymously() async throws -> AuthDataResult { + if let currentUser = requestConfiguration.auth?.currentUser, + currentUser.isAnonymous { + return AuthDataResult(withUser: currentUser, additionalUserInfo: nil) + } + let request = SignUpNewUserRequest(requestConfiguration: requestConfiguration) + let response = try await AuthBackend.call(with: request) + let user = try await completeSignIn( + withAccessToken: response.idToken, + accessTokenExpirationDate: response.approximateExpirationDate, + refreshToken: response.refreshToken, + anonymous: true + ) + // TODO: The ObjC implementation passed a nil providerID to the nonnull providerID + let additionalUserInfo = AdditionalUserInfo(providerID: "", + profile: nil, + username: nil, + isNewUser: true) + return AuthDataResult(withUser: user, additionalUserInfo: additionalUserInfo) + } + + func signIn(withCustomToken token: String) async throws -> AuthDataResult { + let request = VerifyCustomTokenRequest(token: token, + requestConfiguration: requestConfiguration) + let response = try await AuthBackend.call(with: request) + let user = try await completeSignIn( + withAccessToken: response.idToken, + accessTokenExpirationDate: response.approximateExpirationDate, + refreshToken: response.refreshToken, + anonymous: false + ) + // TODO: The ObjC implementation passed a nil providerID to the nonnull providerID + let additionalUserInfo = AdditionalUserInfo(providerID: "", + profile: nil, + username: nil, + isNewUser: response.isNewUser) + return AuthDataResult(withUser: user, additionalUserInfo: additionalUserInfo) + } + + func createUser(withEmail email: String, password: String) async throws -> AuthDataResult { + let request = SignUpNewUserRequest(email: email, + password: password, + displayName: nil, + idToken: nil, + requestConfiguration: requestConfiguration) + #if os(iOS) + let response = try await injectRecaptcha(request: request, + action: AuthRecaptchaAction.signUpPassword) + #else + let response = try await AuthBackend.call(with: request) + #endif + let user = try await completeSignIn( + withAccessToken: response.idToken, + accessTokenExpirationDate: response.approximateExpirationDate, + refreshToken: response.refreshToken, + anonymous: false + ) + let additionalUserInfo = AdditionalUserInfo(providerID: EmailAuthProvider.id, + profile: nil, + username: nil, + isNewUser: true) + return AuthDataResult(withUser: user, additionalUserInfo: additionalUserInfo) + } + + func confirmPasswordReset(withCode code: String, newPassword: String) async throws { + let request = ResetPasswordRequest(oobCode: code, + newPassword: newPassword, + requestConfiguration: requestConfiguration) + _ = try await AuthBackend.call(with: request) + } + + func checkActionCode(_ code: String) async throws -> ActionCodeInfo { + let request = ResetPasswordRequest(oobCode: code, + newPassword: nil, + requestConfiguration: requestConfiguration) + let response = try await AuthBackend.call(with: request) + + let operation = ActionCodeInfo.actionCodeOperation(forRequestType: response.requestType) + guard let email = response.email else { + fatalError("Internal Auth Error: Failed to get a ResetPasswordResponse") + } + return ActionCodeInfo(withOperation: operation, + email: email, + newEmail: response.verifiedEmail) + } + + func verifyPasswordResetCode(_ code: String) async throws -> String { + let info = try await checkActionCode(code) + return info.email + } + + func applyActionCode(_ code: String) async throws { + let request = SetAccountInfoRequest(requestConfiguration: requestConfiguration) + request.oobCode = code + _ = try await AuthBackend.call(with: request) + } + + func sendPasswordReset(withEmail email: String, + actionCodeSettings: ActionCodeSettings? = nil) async throws { + let request = GetOOBConfirmationCodeRequest.passwordResetRequest( + email: email, + actionCodeSettings: actionCodeSettings, + requestConfiguration: requestConfiguration + ) + #if os(iOS) + _ = try await injectRecaptcha(request: request, + action: AuthRecaptchaAction.getOobCode) + #else + _ = try await AuthBackend.call(with: request) + #endif + } + + func sendSignInLink(toEmail email: String, + actionCodeSettings: ActionCodeSettings) async throws { + let request = GetOOBConfirmationCodeRequest.signInWithEmailLinkRequest( + email, + actionCodeSettings: actionCodeSettings, + requestConfiguration: requestConfiguration + ) + #if os(iOS) + _ = try await injectRecaptcha(request: request, + action: AuthRecaptchaAction.getOobCode) + #else + _ = try await AuthBackend.call(with: request) + #endif + } + + func signOut() throws { + guard requestConfiguration.auth?.currentUser != nil else { + return + } + try updateCurrentUser(nil, byForce: false, savingToDisk: true) + } + + func updateCurrentUser(_ user: User) async throws { + if user.requestConfiguration.apiKey != requestConfiguration.apiKey { + // If the API keys are different, then we need to confirm that the user belongs to the same + // project before proceeding. + user.requestConfiguration = requestConfiguration + try await user.reload() + } + try updateCurrentUser(user, byForce: true, savingToDisk: true) + } + + /// Continue with the rest of the Auth object initialization in the worker actor. + func protectedDataInitialization(_ keychainStorageProvider: AuthKeychainStorage) { + // Load current user from Keychain. + guard let auth = requestConfiguration.auth else { + return + } + if let keychainServiceName = Auth.keychainServiceName(forAppName: auth.firebaseAppName) { + auth.keychainServices = AuthKeychainServices(service: keychainServiceName, + storage: keychainStorageProvider) + auth.storedUserManager = AuthStoredUserManager( + serviceName: keychainServiceName, + keychainServices: auth.keychainServices + ) + } + do { + if let storedUserAccessGroup = auth.storedUserManager.getStoredUserAccessGroup() { + try auth.internalUseUserAccessGroup(storedUserAccessGroup) + } else { + let user = try auth.getUser() + try updateCurrentUser(user, byForce: false, savingToDisk: false) + if let user { + auth.tenantID = user.tenantID + auth.lastNotifiedUserToken = user.rawAccessToken() + } + } + } catch { + #if canImport(UIKit) + if (error as NSError).code == AuthErrorCode.keychainError.rawValue { + // If there's a keychain error, assume it is due to the keychain being accessed + // before the device is unlocked as a result of prewarming, and listen for the + // UIApplicationProtectedDataDidBecomeAvailable notification. + auth.addProtectedDataDidBecomeAvailableObserver() + } + #endif + AuthLog.logError(code: "I-AUT000001", + message: "Error loading saved user when starting up: \(error)") + } + + #if os(iOS) + if GULAppEnvironmentUtil.isAppExtension() { + // iOS App extensions should not call [UIApplication sharedApplication], even if + // UIApplication responds to it. + return + } + + // Using reflection here to avoid build errors in extensions. + let sel = NSSelectorFromString("sharedApplication") + guard UIApplication.responds(to: sel), + let rawApplication = UIApplication.perform(sel), + let application = rawApplication.takeUnretainedValue() as? UIApplication else { + return + } + + // Initialize for phone number auth. + tokenManager = AuthAPNSTokenManager(withApplication: application) + auth.appCredentialManager = AuthAppCredentialManager(withKeychain: auth.keychainServices) + auth.notificationManager = AuthNotificationManager( + withApplication: application, + appCredentialManager: auth.appCredentialManager + ) + + GULAppDelegateSwizzler.registerAppDelegateInterceptor(auth) + GULSceneDelegateSwizzler.registerSceneDelegateInterceptor(auth) + #endif + } + + // MARK: User.swift implementations + + func updateEmail(user: User, + email: String?, + password: String?) async throws { + let hadEmailPasswordCredential = user.hasEmailPasswordCredential + try await executeUserUpdateWithChanges(user: user) { userAccount, request in + if let email { + request.email = email + } + if let password { + request.password = password + } + } + if let email { + user.email = email + } + if user.email != nil { + guard !hadEmailPasswordCredential else { + if let error = user.updateKeychain() { + throw error + } + return + } + // The list of providers need to be updated for the newly added email-password provider. + let accessToken = try await user.internalGetToken() + let getAccountInfoRequest = GetAccountInfoRequest(accessToken: accessToken, + requestConfiguration: requestConfiguration) + do { + let accountInfoResponse = try await AuthBackend.call(with: getAccountInfoRequest) + if let users = accountInfoResponse.users { + for userAccountInfo in users { + // Set the account to non-anonymous if there are any providers, even if + // they're not email/password ones. + if let providerUsers = userAccountInfo.providerUserInfo { + if providerUsers.count > 0 { + user.isAnonymous = false + for providerUserInfo in providerUsers { + if providerUserInfo.providerID == EmailAuthProvider.id { + user.hasEmailPasswordCredential = true + break + } + } + } + } + } + } + user.update(withGetAccountInfoResponse: accountInfoResponse) + if let error = user.updateKeychain() { + throw error + } + } catch { + user.signOutIfTokenIsInvalid(withError: error) + throw error + } + } + } + + #if os(iOS) + /// Updates the phone number for the user. On success, the cached user profile data is updated. + /// + /// Invoked asynchronously on the global work queue in the future. + /// - Parameter credential: The new phone number credential corresponding to the phone + /// number to be added to the Firebase account. If a phone number is already linked to the + /// account, this new phone number will replace it. + /// - Parameter isLinkOperation: Boolean value indicating whether or not this is a link + /// operation. + func updateOrLinkPhoneNumber(user: User, credential: PhoneAuthCredential, + isLinkOperation: Bool) async throws { + let accessToken = try await user.internalGetToken() + + guard let configuration = user.auth?.requestConfiguration else { + fatalError("Auth Internal Error: nil value for VerifyPhoneNumberRequest initializer") + } + switch credential.credentialKind { + case .phoneNumber: fatalError("Internal Error: Missing verificationCode") + case let .verification(verificationID, code): + let operation = isLinkOperation ? AuthOperationType.link : AuthOperationType.update + let request = VerifyPhoneNumberRequest(verificationID: verificationID, + verificationCode: code, + operation: operation, + requestConfiguration: configuration) + request.accessToken = accessToken + do { + let verifyResponse = try await AuthBackend.call(with: request) + guard let idToken = verifyResponse.idToken, + let refreshToken = verifyResponse.refreshToken else { + fatalError("Internal Auth Error: missing token in internalUpdateOrLinkPhoneNumber") + } + user.tokenService = SecureTokenService( + withRequestConfiguration: configuration, + accessToken: idToken, + accessTokenExpirationDate: verifyResponse.approximateExpirationDate, + refreshToken: refreshToken + ) + // Get account info to update cached user info. + _ = try await getAccountInfoRefreshingCache(user) + user.isAnonymous = false + if let error = user.updateKeychain() { + throw error + } + } catch { + user.signOutIfTokenIsInvalid(withError: error) + throw error + } + } + } + #endif + + /// 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` + /// - Parameter callback: A block to invoke when the change is complete. Invoked asynchronously on + /// the auth global work queue in the future. + private func executeUserUpdateWithChanges(user: User, + changeBlock: @escaping (GetAccountInfoResponseUser, + SetAccountInfoRequest) + -> Void) async throws { + let userAccountInfo = try await getAccountInfoRefreshingCache(user) + let accessToken = try await user.internalGetToken() + + // Mutate setAccountInfoRequest in block + let setAccountInfoRequest = SetAccountInfoRequest(requestConfiguration: 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: requestConfiguration, + accessToken: idToken, + accessTokenExpirationDate: accountInfoResponse.approximateExpirationDate, + refreshToken: refreshToken + ) + try await user.setTokenService(tokenService: tokenService) + } + } catch { + user.signOutIfTokenIsInvalid(withError: error) + 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.internalGetToken() + let request = GetAccountInfoRequest(accessToken: token, + requestConfiguration: 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 + } + } + + func reauthenticate(with credential: AuthCredential) async throws -> AuthDataResult { + do { + let authResult = try await internalSignInAndRetrieveData( + withCredential: credential, + isReauthentication: true + ) + let user = authResult.user + guard user.uid == requestConfiguration.auth?.getUserID() else { + throw AuthErrorUtils.userMismatchError() + } + try await user.setTokenService(tokenService: user.tokenService) + return authResult + } catch { + if (error as NSError).code == AuthErrorCode.userNotFound.rawValue { + throw AuthErrorUtils.userMismatchError() + } + throw error + } + } + + #if os(iOS) + func reauthenticate(with provider: FederatedAuthProvider, + uiDelegate: AuthUIDelegate?) async throws -> AuthDataResult { + let credential = try await provider.credential(with: uiDelegate) + return try await reauthenticate(with: credential) + } + #endif + + func getIDTokenResult(user: User, + forcingRefresh forceRefresh: Bool) async throws -> AuthTokenResult { + let token = try await user.internalGetToken(forceRefresh: forceRefresh) + let tokenResult = try AuthTokenResult.tokenResult(token: token) + AuthLog.logDebug(code: "I-AUT000017", message: "Actual token expiration date: " + + "\(String(describing: tokenResult.expirationDate))," + + "current date: \(Date())") + return tokenResult + } + + func link(user: User, with credential: AuthCredential) async throws -> AuthDataResult { + if user.providerDataRaw[credential.provider] != nil { + throw AuthErrorUtils.providerAlreadyLinkedError() + } + if let emailCredential = credential as? EmailAuthCredential { + return try await link(user: user, withEmailCredential: emailCredential) + } + #if !os(watchOS) + if let gameCenterCredential = credential as? GameCenterAuthCredential { + return try await link(user: user, withGameCenterCredential: gameCenterCredential) + } + #endif + #if os(iOS) + if let phoneCredential = credential as? PhoneAuthCredential { + return try await link(user: user, withPhoneCredential: phoneCredential) + } + #endif + + let accessToken = try await user.internalGetToken() + let request = VerifyAssertionRequest(providerID: credential.provider, + requestConfiguration: 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 link(user: User, with provider: FederatedAuthProvider, + uiDelegate: AuthUIDelegate?) async throws -> AuthDataResult { + let credential = try await provider.credential(with: uiDelegate) + return try await link(user: user, with: credential) + } + + func unlink(user: User, fromProvider provider: String) async throws -> User { + let accessToken = try await user.internalGetToken() + let request = SetAccountInfoRequest(requestConfiguration: 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: requestConfiguration, + accessToken: idToken, + accessTokenExpirationDate: response.approximateExpirationDate, + refreshToken: refreshToken + ) + try await user.setTokenService(tokenService: tokenService) + return user + } + } catch { + user.signOutIfTokenIsInvalid(withError: error) + throw error + } + + if let error = user.updateKeychain() { + throw error + } + return user + } + + func sendEmailVerification(user: User, + with actionCodeSettings: ActionCodeSettings?) async throws { + let accessToken = try await user.internalGetToken() + let request = GetOOBConfirmationCodeRequest.verifyEmailRequest( + accessToken: accessToken, + actionCodeSettings: actionCodeSettings, + requestConfiguration: requestConfiguration + ) + do { + _ = try await AuthBackend.call(with: request) + } catch { + user.signOutIfTokenIsInvalid(withError: error) + throw error + } + } + + func sendEmailVerification(user: User, + beforeUpdatingEmail newEmail: String, + actionCodeSettings: ActionCodeSettings?) async throws { + let accessToken = try await user.internalGetToken() + let request = GetOOBConfirmationCodeRequest.verifyBeforeUpdateEmail( + accessToken: accessToken, + newEmail: newEmail, + actionCodeSettings: actionCodeSettings, + requestConfiguration: requestConfiguration + ) + do { + _ = try await AuthBackend.call(with: request) + } catch { + user.signOutIfTokenIsInvalid(withError: error) + throw error + } + } + + func delete(user: User) async throws { + let accessToken = try await user.internalGetToken() + let request = DeleteAccountRequest(localID: user.uid, accessToken: accessToken, + requestConfiguration: requestConfiguration) + _ = try await AuthBackend.call(with: request) + try user.auth?.signOutByForce(withUserID: user.uid) + } + + func commitChanges(changeRequest: UserProfileChangeRequest) async throws { + if changeRequest.consumed { + fatalError("Internal Auth Error: commitChanges should only be called once.") + } + changeRequest.consumed = true + + // Return fast if there is nothing to update: + if !changeRequest.photoURLWasSet, !changeRequest.displayNameWasSet { + return + } + let displayName = changeRequest.displayName + let displayNameWasSet = changeRequest.displayNameWasSet + let photoURL = changeRequest.photoURL + let photoURLWasSet = changeRequest.photoURLWasSet + + try await executeUserUpdateWithChanges(user: changeRequest.user) { _, request in + if photoURLWasSet { + request.photoURL = photoURL + } + if displayNameWasSet { + request.displayName = displayName + } + } + if displayNameWasSet { + changeRequest.user.displayName = displayName + } + if photoURLWasSet { + changeRequest.user.photoURL = photoURL + } + if let error = changeRequest.user.updateKeychain() { + throw error + } + } + + private func link(user: User, + withEmailCredential emailCredential: EmailAuthCredential) async throws + -> AuthDataResult { + if user.hasEmailPasswordCredential { + throw AuthErrorUtils.providerAlreadyLinkedError() + } + switch emailCredential.emailType { + case let .password(password): + let result = AuthDataResult(withUser: user, additionalUserInfo: nil) + return try await link( + user: user, + withEmail: emailCredential.email, + password: password, + authResult: result + ) + case let .link(link): + let accessToken = try? await user.internalGetToken() + var queryItems = AuthWebUtils.parseURL(link) + if link.count == 0 { + if let urlComponents = URLComponents(string: link), + let query = urlComponents.query { + queryItems = AuthWebUtils.parseURL(query) + } + } + guard let actionCode = queryItems["oobCode"] else { + fatalError("Internal Auth Error: Missing oobCode") + } + let request = EmailLinkSignInRequest(email: emailCredential.email, + oobCode: actionCode, + requestConfiguration: requestConfiguration) + request.idToken = accessToken + let response = try await AuthBackend.call(with: request) + guard let idToken = response.idToken, + let refreshToken = response.refreshToken else { + fatalError("Internal Auth Error: missing token in EmailLinkSignInResponse") + } + try await updateTokenAndRefreshUser( + user: user, + idToken: idToken, + refreshToken: refreshToken, + expirationDate: response.approximateExpirationDate + ) + return AuthDataResult(withUser: user, additionalUserInfo: nil) + } + } + + private func link(user: User, + withEmail email: String, + password: String, + authResult: AuthDataResult) async throws -> AuthDataResult { + let accessToken = try await user.internalGetToken() + do { + let request = SignUpNewUserRequest(email: email, + password: password, + displayName: nil, + idToken: accessToken, + requestConfiguration: requestConfiguration) + #if os(iOS) + let response = try await injectRecaptcha(request: request, + action: AuthRecaptchaAction.signUpPassword) + #else + let response = try await AuthBackend.call(with: request) + #endif + guard let refreshToken = response.refreshToken, + let idToken = response.idToken else { + fatalError("Internal auth error: Invalid SignUpNewUserResponse") + } + // Update the new token and refresh user info again. + user.tokenService = SecureTokenService( + withRequestConfiguration: requestConfiguration, + accessToken: idToken, + accessTokenExpirationDate: response.approximateExpirationDate, + refreshToken: refreshToken + ) + + let accessToken = try await user.internalGetToken() + let getAccountInfoRequest = GetAccountInfoRequest( + accessToken: accessToken, + requestConfiguration: requestConfiguration + ) + let accountResponse = try await AuthBackend.call(with: getAccountInfoRequest) + user.isAnonymous = false + user.update(withGetAccountInfoResponse: accountResponse) + if let keychainError = user.updateKeychain() { + throw keychainError + } + return authResult + + } catch { + user.signOutIfTokenIsInvalid(withError: error) + throw error + } + } + + #if !os(watchOS) + private func link(user: User, + withGameCenterCredential gameCenterCredential: GameCenterAuthCredential) async throws + -> AuthDataResult { + let accessToken = try await user.internalGetToken() + guard let publicKeyURL = gameCenterCredential.publicKeyURL, + let signature = gameCenterCredential.signature, + let salt = gameCenterCredential.salt else { + fatalError("Internal Auth Error: Nil value field for SignInWithGameCenterRequest") + } + let request = SignInWithGameCenterRequest(playerID: gameCenterCredential.playerID, + teamPlayerID: gameCenterCredential.teamPlayerID, + gamePlayerID: gameCenterCredential.gamePlayerID, + publicKeyURL: publicKeyURL, + signature: signature, + salt: salt, + timestamp: gameCenterCredential.timestamp, + displayName: gameCenterCredential.displayName, + requestConfiguration: requestConfiguration) + request.accessToken = accessToken + let response = try await AuthBackend.call(with: request) + guard let idToken = response.idToken, + let refreshToken = response.refreshToken else { + fatalError("Internal Auth Error: missing token in link(withGameCredential") + } + try await updateTokenAndRefreshUser(user: user, + idToken: idToken, + refreshToken: refreshToken, + expirationDate: response.approximateExpirationDate) + return AuthDataResult(withUser: user, additionalUserInfo: nil) + } + #endif + + #if os(iOS) + private func link(user: User, + withPhoneCredential phoneCredential: PhoneAuthCredential) async throws + -> AuthDataResult { + try await updateOrLinkPhoneNumber(user: user, + credential: phoneCredential, + isLinkOperation: true) + return AuthDataResult(withUser: user, additionalUserInfo: nil) + } + #endif + + // Update the new token and refresh user info again. + private func updateTokenAndRefreshUser(user: User, + idToken: String, + refreshToken: String, + expirationDate: Date?) async throws { + user.tokenService = SecureTokenService( + withRequestConfiguration: requestConfiguration, + accessToken: idToken, + accessTokenExpirationDate: expirationDate, + refreshToken: refreshToken + ) + let accessToken = try await user.internalGetToken() + let getAccountInfoRequest = GetAccountInfoRequest(accessToken: accessToken, + requestConfiguration: 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 + } + } + + /// Update the current user; initializing the user's internal properties correctly, and + /// optionally saving the user to disk. + /// + /// This method is called during: sign in and sign out events, as well as during class + /// initialization time. The only time the saveToDisk parameter should be set to NO is during + /// class initialization time because the user was just read from disk. + /// - Parameter user: The user to use as the current user (including nil, which is passed at sign + /// out time.) + /// - Parameter saveToDisk: Indicates the method should persist the user data to disk. + func updateCurrentUser(_ user: User?, byForce force: Bool, + savingToDisk saveToDisk: Bool) throws { + if user == requestConfiguration.auth?.currentUser { + // TODO: local + requestConfiguration.auth?.possiblyPostAuthStateChangeNotification() + } + if let user { + if user.tenantID != requestConfiguration.auth?.tenantID { + let error = AuthErrorUtils.tenantIDMismatchError() + throw error + } + } + var throwError: Error? + if saveToDisk { + do { + // TODO: call local saveSuer + try requestConfiguration.auth?.saveUser(user) + } catch { + throwError = error + } + } + if throwError == nil || force { + requestConfiguration.auth?.currentUser = user + // TODO: + requestConfiguration.auth?.possiblyPostAuthStateChangeNotification() + } + if let throwError { + throw throwError + } + } + + func useEmulator(withHost host: String, port: Int) async { + // If host is an IPv6 address, it should be formatted with surrounding brackets. + let formattedHost = host.contains(":") ? "[\(host)]" : host + requestConfiguration.emulatorHostAndPort = "\(formattedHost):\(port)" + #if os(iOS) + requestConfiguration.auth?.settings?.appVerificationDisabledForTesting = true + #endif + } + + #if os(iOS) + func canHandleNotification(_ userInfo: [AnyHashable: Any]) async -> Bool { + guard let auth = requestConfiguration.auth else { + return false + } + return auth.notificationManager.canHandle(notification: userInfo) + } + + func canHandle(_ url: URL) -> Bool { + guard let auth = requestConfiguration.auth, + let authURLPresenter = auth.authURLPresenter as? AuthURLPresenter else { + return false + } + return authURLPresenter.canHandle(url: url) + } + + #endif + + func autoTokenRefresh(accessToken: String, retry: Bool, delay: TimeInterval) async { + try? await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000)) + guard let auth = requestConfiguration.auth, + let currentUser = auth.currentUser else { + return + } + let accessToken = currentUser.rawAccessToken() + guard currentUser.rawAccessToken() == accessToken else { + // Another auto refresh must have been scheduled, so keep _autoRefreshScheduled unchanged. + return + } + auth.autoRefreshScheduled = false + if auth.isAppInBackground { + return + } + let uid = currentUser.uid + do { + _ = try await currentUser.internalGetToken(forceRefresh: true) + if auth.currentUser?.uid != uid { + return + } + } catch { + // Kicks off exponential back off logic to retry failed attempt. Starts with one minute + // delay (60 seconds) if this is the first failed attempt. + let rescheduleDelay = retry ? min(delay * 2, 16 * 60) : 60 + auth.scheduleAutoTokenRefresh(withDelay: rescheduleDelay, retry: true) + } + } + + func fetchAccessToken(user: User, + forcingRefresh forceRefresh: Bool) async throws -> (String?, Bool) { + if !forceRefresh, user.tokenService.hasValidAccessToken() { + return (user.tokenService.accessToken, false) + } else { + AuthLog.logDebug(code: "I-AUT000017", message: "Fetching new token from backend.") + return try await user.tokenService.requestAccessToken(retryIfExpired: true) + } + } + + private func internalSignInAndRetrieveData(withCredential credential: AuthCredential, + isReauthentication: Bool) async throws + -> AuthDataResult { + if let emailCredential = credential as? EmailAuthCredential { + // Special case for email/password credentials + switch emailCredential.emailType { + case let .link(link): + // Email link sign in + return try await internalSignInAndRetrieveData(withEmail: emailCredential.email, link: link) + case let .password(password): + // Email password sign in + let user = try await internalSignInUser( + withEmail: emailCredential.email, + password: password + ) + let additionalUserInfo = AdditionalUserInfo(providerID: EmailAuthProvider.id, + profile: nil, + username: nil, + isNewUser: false) + return AuthDataResult(withUser: user, additionalUserInfo: additionalUserInfo) + } + } + #if !os(watchOS) + if let gameCenterCredential = credential as? GameCenterAuthCredential { + return try await signInAndRetrieveData(withGameCenterCredential: gameCenterCredential) + } + #endif + #if os(iOS) + if let phoneCredential = credential as? PhoneAuthCredential { + // Special case for phone auth credentials + let operation = isReauthentication ? AuthOperationType.reauth : + AuthOperationType.signUpOrSignIn + let response = try await signIn(withPhoneCredential: phoneCredential, + operation: operation) + let user = try await completeSignIn(withAccessToken: response.idToken, + accessTokenExpirationDate: response + .approximateExpirationDate, + refreshToken: response.refreshToken, + anonymous: false) + + let additionalUserInfo = AdditionalUserInfo(providerID: PhoneAuthProvider.id, + profile: nil, + username: nil, + isNewUser: response.isNewUser) + return AuthDataResult(withUser: user, additionalUserInfo: additionalUserInfo) + } + #endif + + let request = VerifyAssertionRequest(providerID: credential.provider, + requestConfiguration: requestConfiguration) + request.autoCreate = !isReauthentication + credential.prepare(request) + let response = try await AuthBackend.call(with: request) + if response.needConfirmation { + let email = response.email + let credential = OAuthCredential(withVerifyAssertionResponse: response) + throw AuthErrorUtils.accountExistsWithDifferentCredentialError( + email: email, + updatedCredential: credential + ) + } + guard let providerID = response.providerID, providerID.count > 0 else { + throw AuthErrorUtils.unexpectedResponse(deserializedResponse: response) + } + let user = try await completeSignIn(withAccessToken: response.idToken, + accessTokenExpirationDate: response + .approximateExpirationDate, + refreshToken: response.refreshToken, + anonymous: false) + let additionalUserInfo = AdditionalUserInfo(providerID: providerID, + profile: response.profile, + username: response.username, + isNewUser: response.isNewUser) + let updatedOAuthCredential = OAuthCredential(withVerifyAssertionResponse: response) + return AuthDataResult(withUser: user, + additionalUserInfo: additionalUserInfo, + credential: updatedOAuthCredential) + } + + #if os(iOS) + /// Signs in using a phone credential. + /// - Parameter credential: The Phone Auth credential used to sign in. + /// - Parameter operation: The type of operation for which this sign-in attempt is initiated. + private func signIn(withPhoneCredential credential: PhoneAuthCredential, + operation: AuthOperationType) async throws -> VerifyPhoneNumberResponse { + switch credential.credentialKind { + case let .phoneNumber(phoneNumber, temporaryProof): + let request = VerifyPhoneNumberRequest(temporaryProof: temporaryProof, + phoneNumber: phoneNumber, + operation: operation, + requestConfiguration: requestConfiguration) + return try await AuthBackend.call(with: request) + case let .verification(verificationID, code): + guard verificationID.count > 0 else { + throw AuthErrorUtils.missingVerificationIDError(message: nil) + } + guard code.count > 0 else { + throw AuthErrorUtils.missingVerificationCodeError(message: nil) + } + let request = VerifyPhoneNumberRequest(verificationID: verificationID, + verificationCode: code, + operation: operation, + requestConfiguration: requestConfiguration) + return try await AuthBackend.call(with: request) + } + } + #endif + + #if !os(watchOS) + /// Signs in using a game center credential. + /// - Parameter credential: The Game Center Auth Credential used to sign in. + private func signInAndRetrieveData(withGameCenterCredential credential: GameCenterAuthCredential) async throws + -> AuthDataResult { + guard let publicKeyURL = credential.publicKeyURL, + let signature = credential.signature, + let salt = credential.salt else { + fatalError( + "Internal Auth Error: Game Center credential missing publicKeyURL, signature, or salt" + ) + } + let request = SignInWithGameCenterRequest(playerID: credential.playerID, + teamPlayerID: credential.teamPlayerID, + gamePlayerID: credential.gamePlayerID, + publicKeyURL: publicKeyURL, + signature: signature, + salt: salt, + timestamp: credential.timestamp, + displayName: credential.displayName, + requestConfiguration: requestConfiguration) + let response = try await AuthBackend.call(with: request) + let user = try await completeSignIn(withAccessToken: response.idToken, + accessTokenExpirationDate: response + .approximateExpirationDate, + refreshToken: response.refreshToken, + anonymous: false) + let additionalUserInfo = AdditionalUserInfo(providerID: GameCenterAuthProvider.id, + profile: nil, + username: nil, + isNewUser: response.isNewUser) + return AuthDataResult(withUser: user, additionalUserInfo: additionalUserInfo) + } + + #endif + + /// Signs in using an email and email sign-in link. + /// - Parameter email: The user's email address. + /// - Parameter link: The email sign-in link. + private func internalSignInAndRetrieveData(withEmail email: String, + link: String) async throws -> AuthDataResult { + guard let auth = requestConfiguration.auth, auth.isSignIn(withEmailLink: link) else { + fatalError("The link provided is not valid for email/link sign-in. Please check the link by " + + "calling isSignIn(withEmailLink:) on the Auth instance before attempting to use it " + + "for email/link sign-in.") + } + let queryItems = getQueryItems(link) + guard let actionCode = queryItems["oobCode"] else { + fatalError("Missing oobCode in link URL") + } + let request = EmailLinkSignInRequest(email: email, + oobCode: actionCode, + requestConfiguration: requestConfiguration) + let response = try await AuthBackend.call(with: request) + let user = try await completeSignIn(withAccessToken: response.idToken, + accessTokenExpirationDate: response + .approximateExpirationDate, + refreshToken: response.refreshToken, + anonymous: false) + + let additionalUserInfo = AdditionalUserInfo(providerID: EmailAuthProvider.id, + profile: nil, + username: nil, + isNewUser: response.isNewUser) + return AuthDataResult(withUser: user, additionalUserInfo: additionalUserInfo) + } + + private func getQueryItems(_ link: String) -> [String: String] { + var queryItems = AuthWebUtils.parseURL(link) + if queryItems.count == 0 { + let urlComponents = URLComponents(string: link) + if let query = urlComponents?.query { + queryItems = AuthWebUtils.parseURL(query) + } + } + return queryItems + } + + private func internalSignInUser(withEmail email: String, password: String) async throws -> User { + let request = VerifyPasswordRequest(email: email, + password: password, + requestConfiguration: requestConfiguration) + if request.password.count == 0 { + throw AuthErrorUtils.wrongPasswordError(message: nil) + } + #if os(iOS) + let response = try await injectRecaptcha(request: request, + action: AuthRecaptchaAction.signInWithPassword) + #else + let response = try await AuthBackend.call(with: request) + #endif + return try await completeSignIn( + withAccessToken: response.idToken, + accessTokenExpirationDate: response.approximateExpirationDate, + refreshToken: response.refreshToken, + anonymous: false + ) + } + + #if os(iOS) + func injectRecaptcha(request: T, + action: AuthRecaptchaAction) async throws -> T + .Response { + let recaptchaVerifier = AuthRecaptchaVerifier.shared(auth: requestConfiguration.auth) + if recaptchaVerifier.enablementStatus(forProvider: AuthRecaptchaProvider.password) { + try await recaptchaVerifier.injectRecaptchaFields(request: request, + provider: AuthRecaptchaProvider.password, + action: action) + } else { + do { + return try await AuthBackend.call(with: request) + } catch { + let nsError = error as NSError + if let underlyingError = nsError.userInfo[NSUnderlyingErrorKey] as? NSError, + nsError.code == AuthErrorCode.internalError.rawValue, + let messages = underlyingError + .userInfo[AuthErrorUtils.userInfoDeserializedResponseKey] as? [String: AnyHashable], + let message = messages["message"] as? String, + message.hasPrefix("MISSING_RECAPTCHA_TOKEN") { + try await recaptchaVerifier.injectRecaptchaFields( + request: request, + provider: AuthRecaptchaProvider.password, + action: action + ) + } else { + throw error + } + } + } + return try await AuthBackend.call(with: request) + } + #endif + + private func completeSignIn(withAccessToken accessToken: String?, + accessTokenExpirationDate: Date?, + refreshToken: String?, + anonymous: Bool) async throws -> User { + return try await User.retrieveUser(withAuth: requestConfiguration.auth!, + accessToken: accessToken, + accessTokenExpirationDate: accessTokenExpirationDate, + refreshToken: refreshToken, + anonymous: anonymous) + } + + init(requestConfiguration: AuthRequestConfiguration) { + self.requestConfiguration = requestConfiguration + } +} diff --git a/FirebaseAuth/Sources/Swift/AuthProvider/PhoneAuthProvider.swift b/FirebaseAuth/Sources/Swift/AuthProvider/PhoneAuthProvider.swift index 61a78271347..074c588b5fd 100644 --- a/FirebaseAuth/Sources/Swift/AuthProvider/PhoneAuthProvider.swift +++ b/FirebaseAuth/Sources/Swift/AuthProvider/PhoneAuthProvider.swift @@ -319,7 +319,7 @@ import Foundation } var token: AuthAPNSToken do { - token = try await auth.tokenManager.getToken() + token = try await auth.tokenManagerGet().getToken() } catch { return try await CodeIdentity .recaptcha(reCAPTCHAFlowWithUIDelegate(withUIDelegate: uiDelegate)) diff --git a/FirebaseAuth/Sources/Swift/SystemService/SecureTokenService.swift b/FirebaseAuth/Sources/Swift/SystemService/SecureTokenService.swift index e31f2046f1d..a0e0129f36c 100644 --- a/FirebaseAuth/Sources/Swift/SystemService/SecureTokenService.swift +++ b/FirebaseAuth/Sources/Swift/SystemService/SecureTokenService.swift @@ -83,6 +83,18 @@ class SecureTokenService: NSObject, NSSecureCoding { } } + /// 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 asyncronously 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(user: User, + forcingRefresh forceRefresh: Bool) async throws -> (String?, Bool) { + return try await user.auth.authWorker.fetchAccessToken(user: user, forcingRefresh: forceRefresh) + } + private let taskQueue: AuthSerialTaskQueue // MARK: NSSecureCoding @@ -140,7 +152,7 @@ class SecureTokenService: NSObject, NSSecureCoding { /// 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) { + 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") @@ -186,7 +198,7 @@ class SecureTokenService: NSObject, NSSecureCoding { return (response.accessToken, tokenUpdated) } - private func hasValidAccessToken() -> Bool { + func hasValidAccessToken() -> Bool { if let accessTokenExpirationDate, accessTokenExpirationDate.timeIntervalSinceNow > kFiveMinutes { AuthLog.logDebug(code: "I-AUT000017", diff --git a/FirebaseAuth/Sources/Swift/User/User.swift b/FirebaseAuth/Sources/Swift/User/User.swift index bd56f6f928e..26420d995f2 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 @@ -99,9 +99,16 @@ extension User: NSSecureCoding {} #endif // !FIREBASE_CI @objc(updateEmail:completion:) open func updateEmail(to email: String, completion: ((Error?) -> Void)? = nil) { - kAuthGlobalWorkQueue.async { - self.updateEmail(email: email, password: nil) { error in - User.callInMainThreadWithError(callback: completion, error: error) + Task { + do { + try await auth.authWorker.updateEmail(user: self, email: email, password: nil) + await MainActor.run { + completion?(nil) + } + } catch { + await MainActor.run { + completion?(error) + } } } } @@ -141,15 +148,7 @@ extension User: NSSecureCoding {} #endif // !FIREBASE_CI @available(iOS 13, tvOS 13, macOS 10.15, macCatalyst 13, watchOS 7, *) open func updateEmail(to email: String) async throws { - return try await withCheckedThrowingContinuation { continuation in - self.updateEmail(to: email) { error in - if let error { - continuation.resume(throwing: error) - } else { - continuation.resume() - } - } - } + try await auth.authWorker.updateEmail(user: self, email: email, password: nil) } /// Updates the password for the user. On success, the cached user profile data is updated. @@ -171,15 +170,16 @@ extension User: NSSecureCoding {} /// finished. @objc(updatePassword:completion:) open func updatePassword(to password: String, completion: ((Error?) -> Void)? = nil) { - guard password.count > 0 else { - if let completion { - completion(AuthErrorUtils.weakPasswordError(serverResponseReason: "Missing Password")) - } - return - } - kAuthGlobalWorkQueue.async { - self.updateEmail(email: nil, password: password) { error in - User.callInMainThreadWithError(callback: completion, error: error) + Task { + do { + try await self.updatePassword(to: password) + await MainActor.run { + completion?(nil) + } + } catch { + await MainActor.run { + completion?(error) + } } } } @@ -201,15 +201,10 @@ extension User: NSSecureCoding {} /// - Parameter password: The new password for the user. @available(iOS 13, tvOS 13, macOS 10.15, macCatalyst 13, watchOS 7, *) open func updatePassword(to password: String) async throws { - return try await withCheckedThrowingContinuation { continuation in - self.updatePassword(to: password) { error in - if let error { - continuation.resume(throwing: error) - } else { - continuation.resume() - } - } + guard password.count > 0 else { + throw AuthErrorUtils.weakPasswordError(serverResponseReason: "Missing Password") } + return try await auth.authWorker.updateEmail(user: self, email: nil, password: password) } #if os(iOS) @@ -232,10 +227,16 @@ extension User: NSSecureCoding {} @objc(updatePhoneNumberCredential:completion:) open func updatePhoneNumber(_ credential: PhoneAuthCredential, completion: ((Error?) -> Void)? = nil) { - kAuthGlobalWorkQueue.async { - self.internalUpdateOrLinkPhoneNumber(credential: credential, - isLinkOperation: false) { error in - User.callInMainThreadWithError(callback: completion, error: error) + Task { + do { + try await self.updatePhoneNumber(credential) + await MainActor.run { + completion?(nil) + } + } catch { + await MainActor.run { + completion?(error) + } } } } @@ -256,15 +257,9 @@ extension User: NSSecureCoding {} /// account this new phone number will replace it. @available(iOS 13, tvOS 13, macOS 10.15, macCatalyst 13, watchOS 7, *) open func updatePhoneNumber(_ credential: PhoneAuthCredential) async throws { - return try await withCheckedThrowingContinuation { continuation in - self.updatePhoneNumber(credential) { error in - if let error { - continuation.resume(throwing: error) - } else { - continuation.resume() - } - } - } + try await auth.authWorker.updateOrLinkPhoneNumber(user: self, + credential: credential, + isLinkOperation: false) } #endif @@ -301,9 +296,16 @@ extension User: NSSecureCoding {} /// - Parameter completion: Optionally; the block invoked when the reload has finished. Invoked /// asynchronously on the main thread in the future. @objc open func reload(completion: ((Error?) -> Void)? = nil) { - kAuthGlobalWorkQueue.async { - self.getAccountInfoRefreshingCache { user, error in - User.callInMainThreadWithError(callback: completion, error: error) + Task { + do { + try await self.reload() + await MainActor.run { + completion?(nil) + } + } catch { + await MainActor.run { + completion?(error) + } } } } @@ -315,15 +317,7 @@ extension User: NSSecureCoding {} /// `updateEmail(to:)`. @available(iOS 13, tvOS 13, macOS 10.15, macCatalyst 13, watchOS 7, *) open func reload() async throws { - return try await withCheckedThrowingContinuation { continuation in - self.reload { error in - if let error { - continuation.resume(throwing: error) - } else { - continuation.resume() - } - } - } + let _ = try await auth.authWorker.getAccountInfoRefreshingCache(self) } /// Renews the user's authentication tokens by validating a fresh set of credentials supplied @@ -359,39 +353,15 @@ extension User: NSSecureCoding {} @objc(reauthenticateWithCredential:completion:) open func reauthenticate(with credential: AuthCredential, completion: ((AuthDataResult?, Error?) -> Void)? = nil) { - kAuthGlobalWorkQueue.async { - Task { - do { - let authResult = try await self.auth?.internalSignInAndRetrieveData( - withCredential: credential, - isReauthentication: true - ) - guard let user = authResult?.user, - user.uid == self.auth?.getUserID() else { - User.callInMainThreadWithAuthDataResultAndError( - callback: completion, - result: authResult, - error: AuthErrorUtils.userMismatchError() - ) - return - } - // Successful reauthenticate - self.setTokenService(tokenService: user.tokenService) { error in - User.callInMainThreadWithAuthDataResultAndError(callback: completion, - result: authResult, - error: error) - } - } catch { - // If "user not found" error returned by backend, - // translate to user mismatch error which is more - // accurate. - var reportError: Error = error - if (error as NSError).code == AuthErrorCode.userNotFound.rawValue { - reportError = AuthErrorUtils.userMismatchError() - } - User.callInMainThreadWithAuthDataResultAndError(callback: completion, - result: nil, - error: reportError) + Task { + do { + let result = try await reauthenticate(with: credential) + await MainActor.run { + completion?(result, nil) + } + } catch { + await MainActor.run { + completion?(nil, error) } } } @@ -429,15 +399,7 @@ extension User: NSSecureCoding {} @available(iOS 13, tvOS 13, macOS 10.15, macCatalyst 13, watchOS 7, *) @discardableResult open func reauthenticate(with credential: AuthCredential) async throws -> AuthDataResult { - return try await withCheckedThrowingContinuation { continuation in - self.reauthenticate(with: credential) { result, error in - if let result { - continuation.resume(returning: result) - } else if let error { - continuation.resume(throwing: error) - } - } - } + return try await auth.authWorker.reauthenticate(with: credential) } #if os(iOS) @@ -455,15 +417,15 @@ extension User: NSSecureCoding {} open func reauthenticate(with provider: FederatedAuthProvider, uiDelegate: AuthUIDelegate?, completion: ((AuthDataResult?, Error?) -> Void)? = nil) { - kAuthGlobalWorkQueue.async { - Task { - do { - let credential = try await provider.credential(with: uiDelegate) - self.reauthenticate(with: credential, completion: completion) - } catch { - if let completion { - completion(nil, error) - } + Task { + do { + let result = try await reauthenticate(with: provider, uiDelegate: uiDelegate) + await MainActor.run { + completion?(result, nil) + } + } catch { + await MainActor.run { + completion?(nil, error) } } } @@ -482,15 +444,7 @@ extension User: NSSecureCoding {} @discardableResult open func reauthenticate(with provider: FederatedAuthProvider, uiDelegate: AuthUIDelegate?) async throws -> AuthDataResult { - return try await withCheckedThrowingContinuation { continuation in - self.reauthenticate(with: provider, uiDelegate: uiDelegate) { result, error in - if let result { - continuation.resume(returning: result) - } else if let error { - continuation.resume(throwing: error) - } - } - } + return try await auth.authWorker.reauthenticate(with: provider, uiDelegate: uiDelegate) } #endif @@ -515,10 +469,15 @@ extension User: NSSecureCoding {} @objc(getIDTokenForcingRefresh:completion:) open func getIDTokenForcingRefresh(_ forceRefresh: Bool, completion: ((String?, Error?) -> Void)?) { - getIDTokenResult(forcingRefresh: forceRefresh) { tokenResult, error in - if let completion { - DispatchQueue.main.async { - completion(tokenResult?.token, error) + Task { + do { + let tokenResult = try await getIDTokenResult(forcingRefresh: forceRefresh) + await MainActor.run { + completion?(tokenResult.token, nil) + } + } catch { + await MainActor.run { + completion?(nil, error) } } } @@ -533,15 +492,7 @@ extension User: NSSecureCoding {} /// - Returns: The Firebase authentication token. @available(iOS 13, tvOS 13, macOS 10.15, macCatalyst 13, watchOS 7, *) open func getIDToken(forcingRefresh forceRefresh: Bool = false) async throws -> String { - return try await withCheckedThrowingContinuation { continuation in - self.getIDTokenForcingRefresh(forceRefresh) { tokenResult, error in - if let tokenResult { - continuation.resume(returning: tokenResult) - } else if let error { - continuation.resume(throwing: error) - } - } - } + return try await getIDTokenResult(forcingRefresh: forceRefresh).token } /// API included for compatibility with a mis-named Firebase 10 API. @@ -555,13 +506,7 @@ extension User: NSSecureCoding {} /// asynchronously on the main thread in the future. @objc(getIDTokenResultWithCompletion:) open func getIDTokenResult(completion: ((AuthTokenResult?, Error?) -> Void)?) { - getIDTokenResult(forcingRefresh: false) { tokenResult, error in - if let completion { - DispatchQueue.main.async { - completion(tokenResult, error) - } - } - } + getIDTokenResult(forcingRefresh: false, completion: completion) } /// Retrieves the Firebase authentication token, possibly refreshing it if it has expired. @@ -576,34 +521,15 @@ extension User: NSSecureCoding {} @objc(getIDTokenResultForcingRefresh:completion:) open func getIDTokenResult(forcingRefresh: Bool, completion: ((AuthTokenResult?, Error?) -> Void)?) { - kAuthGlobalWorkQueue.async { - self.internalGetToken(forceRefresh: forcingRefresh) { token, error in - var tokenResult: AuthTokenResult? - if let token { - do { - tokenResult = try AuthTokenResult.tokenResult(token: token) - AuthLog.logDebug(code: "I-AUT000017", message: "Actual token expiration date: " + - "\(String(describing: tokenResult?.expirationDate))," + - "current date: \(Date())") - if let completion { - DispatchQueue.main.async { - completion(tokenResult, error) - } - } - return - } catch { - if let completion { - DispatchQueue.main.async { - completion(tokenResult, error) - } - } - return - } + Task { + do { + let tokenResult = try await getIDTokenResult(forcingRefresh: forcingRefresh) + await MainActor.run { + completion?(tokenResult, nil) } - if let completion { - DispatchQueue.main.async { - completion(nil, error) - } + } catch { + await MainActor.run { + completion?(nil, error) } } } @@ -619,15 +545,7 @@ extension User: NSSecureCoding {} @available(iOS 13, tvOS 13, macOS 10.15, macCatalyst 13, watchOS 7, *) open func getIDTokenResult(forcingRefresh forceRefresh: Bool = false) async throws -> AuthTokenResult { - return try await withCheckedThrowingContinuation { continuation in - self.getIDTokenResult(forcingRefresh: forceRefresh) { tokenResult, error in - if let tokenResult { - continuation.resume(returning: tokenResult) - } else if let error { - continuation.resume(throwing: error) - } - } - } + try await auth.authWorker.getIDTokenResult(user: self, forcingRefresh: forceRefresh) } /// Associates a user account from a third-party identity provider with this user and @@ -652,79 +570,15 @@ extension User: NSSecureCoding {} @objc(linkWithCredential:completion:) open func link(with credential: AuthCredential, completion: ((AuthDataResult?, Error?) -> Void)? = nil) { - kAuthGlobalWorkQueue.async { - if self.providerDataRaw[credential.provider] != nil { - User.callInMainThreadWithAuthDataResultAndError( - callback: completion, - result: nil, - error: AuthErrorUtils.providerAlreadyLinkedError() - ) - return - } - if let emailCredential = credential as? EmailAuthCredential { - self.link(withEmailCredential: emailCredential, completion: completion) - return - } - #if !os(watchOS) - if let gameCenterCredential = credential as? GameCenterAuthCredential { - self.link(withGameCenterCredential: gameCenterCredential, completion: completion) - return - } - #endif - #if os(iOS) - if let phoneCredential = credential as? PhoneAuthCredential { - self.link(withPhoneCredential: phoneCredential, completion: completion) - return - } - #endif - - self.taskQueue.enqueueTask { complete in - let completeWithError = { result, error in - complete() - User.callInMainThreadWithAuthDataResultAndError(callback: completion, result: result, - error: error) + Task { + do { + let tokenResult = try await link(with: credential) + await MainActor.run { + completion?(tokenResult, nil) } - 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.") - } - 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 EmailLinkSignInResponse") - } - let additionalUserInfo = AdditionalUserInfo(providerID: providerID, - profile: response.profile, - username: response.username, - isNewUser: response.isNewUser) - let updatedOAuthCredential = OAuthCredential(withVerifyAssertionResponse: response) - let result = AuthDataResult(withUser: self, additionalUserInfo: additionalUserInfo, - credential: updatedOAuthCredential) - self.updateTokenAndRefreshUser(idToken: idToken, - refreshToken: refreshToken, - accessToken: accessToken, - expirationDate: response.approximateExpirationDate, - result: result, - requestConfiguration: requestConfiguration, - completion: completion, - withTaskComplete: complete) - } catch { - self.signOutIfTokenIsInvalid(withError: error) - completeWithError(nil, error) - return - } - } + } catch { + await MainActor.run { + completion?(nil, error) } } } @@ -751,15 +605,7 @@ extension User: NSSecureCoding {} @available(iOS 13, tvOS 13, macOS 10.15, macCatalyst 13, watchOS 7, *) @discardableResult open func link(with credential: AuthCredential) async throws -> AuthDataResult { - return try await withCheckedThrowingContinuation { continuation in - self.link(with: credential) { result, error in - if let result { - continuation.resume(returning: result) - } else if let error { - continuation.resume(throwing: error) - } - } - } + return try await auth.authWorker.link(user: self, with: credential) } #if os(iOS) @@ -776,15 +622,15 @@ extension User: NSSecureCoding {} open func link(with provider: FederatedAuthProvider, uiDelegate: AuthUIDelegate?, completion: ((AuthDataResult?, Error?) -> Void)? = nil) { - kAuthGlobalWorkQueue.async { - Task { - do { - let credential = try await provider.credential(with: uiDelegate) - self.link(with: credential, completion: completion) - } catch { - if let completion { - completion(nil, error) - } + Task { + do { + let tokenResult = try await link(with: provider, uiDelegate: uiDelegate) + await MainActor.run { + completion?(tokenResult, nil) + } + } catch { + await MainActor.run { + completion?(nil, error) } } } @@ -800,19 +646,10 @@ extension User: NSSecureCoding {} /// - Parameter completion: Optionally; a block which is invoked when the link flow finishes, or /// is canceled. Invoked asynchronously on the main thread in the future. /// - Returns: An AuthDataResult. - @available(iOS 13, tvOS 13, macOS 10.15, macCatalyst 13, watchOS 7, *) @discardableResult open func link(with provider: FederatedAuthProvider, uiDelegate: AuthUIDelegate?) async throws -> AuthDataResult { - return try await withCheckedThrowingContinuation { continuation in - self.link(with: provider, uiDelegate: uiDelegate) { result, error in - if let result { - continuation.resume(returning: result) - } else if let error { - continuation.resume(throwing: error) - } - } - } + return try await auth.authWorker.link(user: self, with: provider, uiDelegate: uiDelegate) } #endif @@ -832,67 +669,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) } } } @@ -913,15 +698,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 auth.authWorker.unlink(user: self, fromProvider: provider) } /// Initiates email verification for the user. @@ -958,31 +735,15 @@ extension User: NSSecureCoding {} @objc(sendEmailVerificationWithActionCodeSettings:completion:) open func sendEmailVerification(with actionCodeSettings: ActionCodeSettings? = nil, completion: ((Error?) -> Void)? = nil) { - kAuthGlobalWorkQueue.async { - self.internalGetToken { accessToken, error in - if let error { - User.callInMainThreadWithError(callback: completion, error: error) - return - } - guard let accessToken else { - fatalError("Internal Error: Both error and accessToken are nil.") + Task { + do { + try await sendEmailVerification(with: actionCodeSettings) + await MainActor.run { + completion?(nil) } - guard let requestConfiguration = self.auth?.requestConfiguration else { - fatalError("Internal Error: Unexpected nil requestConfiguration.") - } - let request = GetOOBConfirmationCodeRequest.verifyEmailRequest( - accessToken: accessToken, - actionCodeSettings: actionCodeSettings, - requestConfiguration: requestConfiguration - ) - Task { - do { - let _ = try await AuthBackend.call(with: request) - User.callInMainThreadWithError(callback: completion, error: nil) - } catch { - self.signOutIfTokenIsInvalid(withError: error) - User.callInMainThreadWithError(callback: completion, error: error) - } + } catch { + await MainActor.run { + completion?(error) } } } @@ -1002,15 +763,7 @@ extension User: NSSecureCoding {} /// handling action codes. The default value is `nil`. @available(iOS 13, tvOS 13, macOS 10.15, macCatalyst 13, watchOS 7, *) open func sendEmailVerification(with actionCodeSettings: ActionCodeSettings? = nil) async throws { - return try await withCheckedThrowingContinuation { continuation in - self.sendEmailVerification(with: actionCodeSettings) { error in - if let error { - continuation.resume(throwing: error) - } else { - continuation.resume() - } - } - } + return try await auth.authWorker.sendEmailVerification(user: self, with: actionCodeSettings) } /// Deletes the user account (also signs out the user, if this was the current user). @@ -1023,28 +776,15 @@ extension User: NSSecureCoding {} /// - Parameter completion: Optionally; the block invoked when the request to delete the account /// is complete, or fails. Invoked asynchronously on the main thread in the future. @objc open func delete(completion: ((Error?) -> Void)? = nil) { - kAuthGlobalWorkQueue.async { - self.internalGetToken { accessToken, error in - if let error { - User.callInMainThreadWithError(callback: completion, error: error) - return - } - guard let accessToken else { - fatalError("Auth Internal Error: Both error and accessToken are nil.") + Task { + do { + try await delete() + await MainActor.run { + completion?(nil) } - guard let requestConfiguration = self.auth?.requestConfiguration else { - fatalError("Auth Internal Error: Unexpected nil requestConfiguration.") - } - let request = DeleteAccountRequest(localID: self.uid, accessToken: accessToken, - requestConfiguration: requestConfiguration) - Task { - do { - let _ = try await AuthBackend.call(with: request) - try self.auth?.signOutByForce(withUserID: self.uid) - User.callInMainThreadWithError(callback: completion, error: nil) - } catch { - User.callInMainThreadWithError(callback: completion, error: error) - } + } catch { + await MainActor.run { + completion?(error) } } } @@ -1059,15 +799,7 @@ extension User: NSSecureCoding {} /// `reauthenticate(with:)`. @available(iOS 13, tvOS 13, macOS 10.15, macCatalyst 13, watchOS 7, *) open func delete() async throws { - return try await withCheckedThrowingContinuation { continuation in - self.delete { error in - if let error { - continuation.resume(throwing: error) - } else { - continuation.resume() - } - } - } + return try await auth.authWorker.delete(user: self) } /// Send an email to verify the ownership of the account then update to the new email. @@ -1088,31 +820,16 @@ extension User: NSSecureCoding {} @objc open func sendEmailVerification(beforeUpdatingEmail email: String, actionCodeSettings: ActionCodeSettings? = nil, completion: ((Error?) -> Void)? = nil) { - kAuthGlobalWorkQueue.async { - self.internalGetToken { accessToken, error in - if let error { - User.callInMainThreadWithError(callback: completion, error: error) - return - } - guard let accessToken else { - fatalError("Internal Error: Both error and accessToken are nil.") + Task { + do { + try await sendEmailVerification(beforeUpdatingEmail: email, + actionCodeSettings: actionCodeSettings) + await MainActor.run { + completion?(nil) } - guard let requestConfiguration = self.auth?.requestConfiguration else { - fatalError("Internal Error: Unexpected nil requestConfiguration.") - } - let request = GetOOBConfirmationCodeRequest.verifyBeforeUpdateEmail( - accessToken: accessToken, - newEmail: email, - actionCodeSettings: actionCodeSettings, - requestConfiguration: requestConfiguration - ) - Task { - do { - let _ = try await AuthBackend.call(with: request) - User.callInMainThreadWithError(callback: completion, error: nil) - } catch { - User.callInMainThreadWithError(callback: completion, error: error) - } + } catch { + await MainActor.run { + completion?(error) } } } @@ -1125,16 +842,11 @@ extension User: NSSecureCoding {} @available(iOS 13, tvOS 13, macOS 10.15, macCatalyst 13, watchOS 7, *) open func sendEmailVerification(beforeUpdatingEmail newEmail: String, actionCodeSettings: ActionCodeSettings? = nil) async throws { - return try await withCheckedThrowingContinuation { continuation in - self.sendEmailVerification(beforeUpdatingEmail: newEmail, - actionCodeSettings: actionCodeSettings) { error in - if let error { - continuation.resume(throwing: error) - } else { - continuation.resume() - } - } - } + return try await auth.authWorker.sendEmailVerification( + user: self, + beforeUpdatingEmail: newEmail, + actionCodeSettings: actionCodeSettings + ) } // MARK: Internal implementations below @@ -1180,7 +892,7 @@ extension User: NSSecureCoding {} user.auth = auth user.tenantID = auth.tenantID user.requestConfiguration = auth.requestConfiguration - let accessToken2 = try await user.internalGetTokenAsync() + let accessToken2 = try await user.internalGetToken() let getAccountInfoRequest = GetAccountInfoRequest( accessToken: accessToken2, requestConfiguration: user.requestConfiguration @@ -1213,7 +925,7 @@ 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 @@ -1228,7 +940,7 @@ extension User: NSSecureCoding {} private weak var _auth: Auth? /// A weak reference to an `Auth` instance associated with this instance. - weak var auth: Auth? { + weak var auth: Auth! { set { _auth = newValue guard let requestConfiguration = auth?.requestConfiguration else { @@ -1242,151 +954,14 @@ extension User: NSSecureCoding {} // MARK: Private functions - private func updateEmail(email: String?, - password: String?, - callback: @escaping (Error?) -> Void) { - let hadEmailPasswordCredential = hasEmailPasswordCredential - executeUserUpdateWithChanges(changeBlock: { user, request in - if let email { - request.email = email - } - if let password { - request.password = password - } - }) { error in - if let error { - callback(error) - return - } - if let email { - self.email = email - } - if self.email != nil { - if !hadEmailPasswordCredential { - // The list of providers need to be updated for the newly added email-password provider. - self.internalGetToken { accessToken, error in - if let error { - callback(error) - return - } - guard let accessToken else { - fatalError("Auth Internal Error: Both accessToken and error are nil") - } - if let requestConfiguration = self.auth?.requestConfiguration { - let getAccountInfoRequest = GetAccountInfoRequest(accessToken: accessToken, - requestConfiguration: requestConfiguration) - Task { - do { - let accountInfoResponse = try await AuthBackend.call(with: getAccountInfoRequest) - if let users = accountInfoResponse.users { - for userAccountInfo in users { - // Set the account to non-anonymous if there are any providers, even if - // they're not email/password ones. - if let providerUsers = userAccountInfo.providerUserInfo { - if providerUsers.count > 0 { - self.isAnonymous = false - for providerUserInfo in providerUsers { - if providerUserInfo.providerID == EmailAuthProvider.id { - self.hasEmailPasswordCredential = true - break - } - } - } - } - } - } - self.update(withGetAccountInfoResponse: accountInfoResponse) - if let error = self.updateKeychain() { - callback(error) - return - } - callback(nil) - } catch { - self.signOutIfTokenIsInvalid(withError: error) - callback(error) - } - } - } - } - return - } - } - if let error = self.updateKeychain() { - callback(error) - return - } - callback(nil) - } - } - - /// 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` - /// - Parameter callback: A block to invoke when the change is complete. Invoked asynchronously on - /// the auth global work queue in the future. - 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 - } - guard let user else { - fatalError("Internal error: Both user and error are nil") - } - self.internalGetToken { accessToken, error in - if let error { - complete() - callback(error) - return - } - if let configuration = self.auth?.requestConfiguration { - // Mutate setAccountInfoRequest in block - let setAccountInfoRequest = SetAccountInfoRequest(requestConfiguration: configuration) - setAccountInfoRequest.accessToken = accessToken - changeBlock(user, setAccountInfoRequest) - Task { - 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) - } - } - } - } - } - } - } - /// 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) { + func setTokenService(tokenService: SecureTokenService, + callback: @escaping (Error?) -> Void) { tokenService.fetchAccessToken(forcingRefresh: false) { token, error, tokenUpdated in if let error { callback(error) @@ -1401,42 +976,19 @@ extension User: NSSecureCoding {} } } - /// 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) - callback(nil, 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. + func setTokenService(tokenService: SecureTokenService) async throws { + self.tokenService = tokenService + if let error = updateKeychain() { + throw 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") @@ -1471,341 +1023,9 @@ extension User: NSSecureCoding {} #endif } - #if os(iOS) - /// Updates the phone number for the user. On success, the cached user profile data is updated. - /// - /// Invoked asynchronously on the global work queue in the future. - /// - Parameter credential: The new phone number credential corresponding to the phone - /// number to be added to the Firebase account. If a phone number is already linked to the - /// account, this new phone number will replace it. - /// - Parameter isLinkOperation: Boolean value indicating whether or not this is a link - /// operation. - /// - Parameter completion: Optionally; the block invoked when the user profile change has - /// finished. - private func internalUpdateOrLinkPhoneNumber(credential: PhoneAuthCredential, - isLinkOperation: Bool, - completion: @escaping (Error?) -> Void) { - internalGetToken { accessToken, error in - if let error { - completion(error) - return - } - guard let accessToken = accessToken else { - fatalError("Auth Internal Error: Both accessToken and error are nil") - } - guard let configuration = self.auth?.requestConfiguration else { - fatalError("Auth Internal Error: nil value for VerifyPhoneNumberRequest initializer") - } - switch credential.credentialKind { - case .phoneNumber: fatalError("Internal Error: Missing verificationCode") - case let .verification(verificationID, code): - let operation = isLinkOperation ? AuthOperationType.link : AuthOperationType.update - let request = VerifyPhoneNumberRequest(verificationID: verificationID, - verificationCode: code, - operation: operation, - requestConfiguration: configuration) - request.accessToken = accessToken - Task { - do { - let verifyResponse = try await AuthBackend.call(with: request) - guard let idToken = verifyResponse.idToken, - let refreshToken = verifyResponse.refreshToken else { - fatalError("Internal Auth Error: missing token in internalUpdateOrLinkPhoneNumber") - } - self.tokenService = SecureTokenService( - withRequestConfiguration: configuration, - accessToken: idToken, - accessTokenExpirationDate: verifyResponse.approximateExpirationDate, - refreshToken: refreshToken - ) - // Get account info to update cached user info. - self.getAccountInfoRefreshingCache { user, error in - if let error { - self.signOutIfTokenIsInvalid(withError: error) - completion(error) - return - } - self.isAnonymous = false - if let error = self.updateKeychain() { - completion(error) - return - } - completion(nil) - } - } catch { - self.signOutIfTokenIsInvalid(withError: error) - completion(error) - } - } - } - } - } - #endif - - private func link(withEmail email: String, - password: String, - authResult: AuthDataResult, - _ completion: ((AuthDataResult?, Error?) -> Void)?) { - internalGetToken { accessToken, error in - guard let requestConfiguration = self.auth?.requestConfiguration else { - fatalError("Internal auth error: missing auth on User") - } - let request = SignUpNewUserRequest(email: email, - password: password, - displayName: nil, - idToken: accessToken, - requestConfiguration: requestConfiguration) - Task { - do { - #if os(iOS) - guard let auth = self.auth else { - fatalError("Internal Auth error: missing auth instance on user") - } - let response = try await auth.injectRecaptcha(request: request, - action: AuthRecaptchaAction - .signUpPassword) - #else - let response = try await AuthBackend.call(with: request) - #endif - guard let refreshToken = response.refreshToken, - let idToken = response.idToken else { - fatalError("Internal auth error: Invalid SignUpNewUserResponse") - } - // Update the new token and refresh user info again. - self.tokenService = SecureTokenService( - withRequestConfiguration: self.requestConfiguration, - accessToken: idToken, - accessTokenExpirationDate: response.approximateExpirationDate, - refreshToken: refreshToken - ) - - self.internalGetToken { accessToken, error in - if let error { - User.callInMainThreadWithAuthDataResultAndError(callback: completion, - complete: nil, result: nil, - error: error) - return - } - guard let accessToken else { - fatalError("Internal Auth Error: nil accessToken") - } - let getAccountInfoRequest = GetAccountInfoRequest( - accessToken: accessToken, - requestConfiguration: self.requestConfiguration - ) - Task { - do { - let response = try await AuthBackend.call(with: getAccountInfoRequest) - self.isAnonymous = false - self.update(withGetAccountInfoResponse: response) - if let keychainError = self.updateKeychain() { - User.callInMainThreadWithAuthDataResultAndError(callback: completion, - complete: nil, result: nil, - error: keychainError) - return - } - User.callInMainThreadWithAuthDataResultAndError(callback: completion, - complete: nil, - result: authResult) - } catch { - self.signOutIfTokenIsInvalid(withError: error) - User.callInMainThreadWithAuthDataResultAndError(callback: completion, - complete: nil, result: nil, - error: error) - } - } - } - } catch { - self.signOutIfTokenIsInvalid(withError: error) - User.callInMainThreadWithAuthDataResultAndError(callback: completion, - complete: nil, result: nil, error: error) - } - } - } - } - - private func link(withEmailCredential emailCredential: EmailAuthCredential, - completion: ((AuthDataResult?, Error?) -> Void)?) { - if hasEmailPasswordCredential { - User.callInMainThreadWithAuthDataResultAndError( - callback: completion, - result: nil, - error: AuthErrorUtils - .providerAlreadyLinkedError() - ) - return - } - switch emailCredential.emailType { - case let .password(password): - let result = AuthDataResult(withUser: self, additionalUserInfo: nil) - link(withEmail: emailCredential.email, password: password, authResult: result, completion) - case let .link(link): - internalGetToken { accessToken, error in - var queryItems = AuthWebUtils.parseURL(link) - if link.count == 0 { - if let urlComponents = URLComponents(string: link), - let query = urlComponents.query { - queryItems = AuthWebUtils.parseURL(query) - } - } - guard let actionCode = queryItems["oobCode"], - let requestConfiguration = self.auth?.requestConfiguration else { - fatalError("Internal Auth Error: Missing oobCode or requestConfiguration") - } - let request = EmailLinkSignInRequest(email: emailCredential.email, - oobCode: actionCode, - requestConfiguration: requestConfiguration) - request.idToken = accessToken - Task { - do { - let response = try await AuthBackend.call(with: request) - guard let idToken = response.idToken, - let refreshToken = response.refreshToken else { - fatalError("Internal Auth Error: missing token in EmailLinkSignInResponse") - } - self.updateTokenAndRefreshUser(idToken: idToken, - refreshToken: refreshToken, - accessToken: accessToken, - expirationDate: response.approximateExpirationDate, - result: AuthDataResult( - withUser: self, - additionalUserInfo: nil - ), - requestConfiguration: requestConfiguration, - completion: completion) - } catch { - User.callInMainThreadWithAuthDataResultAndError(callback: completion, - result: nil, - error: error) - } - } - } - } - } - - #if !os(watchOS) - private func link(withGameCenterCredential gameCenterCredential: GameCenterAuthCredential, - completion: ((AuthDataResult?, Error?) -> Void)?) { - internalGetToken { accessToken, error in - guard let requestConfiguration = self.auth?.requestConfiguration, - let publicKeyURL = gameCenterCredential.publicKeyURL, - let signature = gameCenterCredential.signature, - let salt = gameCenterCredential.salt else { - fatalError("Internal Auth Error: Nil value field for SignInWithGameCenterRequest") - } - let request = SignInWithGameCenterRequest(playerID: gameCenterCredential.playerID, - teamPlayerID: gameCenterCredential.teamPlayerID, - gamePlayerID: gameCenterCredential.gamePlayerID, - publicKeyURL: publicKeyURL, - signature: signature, - salt: salt, - timestamp: gameCenterCredential.timestamp, - displayName: gameCenterCredential.displayName, - requestConfiguration: requestConfiguration) - request.accessToken = accessToken - Task { - do { - let response = try await AuthBackend.call(with: request) - guard let idToken = response.idToken, - let refreshToken = response.refreshToken else { - fatalError("Internal Auth Error: missing token in link(withGameCredential") - } - self.updateTokenAndRefreshUser(idToken: idToken, - refreshToken: refreshToken, - accessToken: accessToken, - expirationDate: response.approximateExpirationDate, - result: AuthDataResult( - withUser: self, - additionalUserInfo: nil - ), - requestConfiguration: requestConfiguration, - completion: completion) - } catch { - User.callInMainThreadWithAuthDataResultAndError(callback: completion, - result: nil, - error: error) - } - } - } - } - #endif - - #if os(iOS) - private func link(withPhoneCredential phoneCredential: PhoneAuthCredential, - completion: ((AuthDataResult?, Error?) -> Void)?) { - internalUpdateOrLinkPhoneNumber(credential: phoneCredential, - isLinkOperation: true) { error in - if let error { - User.callInMainThreadWithAuthDataResultAndError( - callback: completion, - result: nil, - error: error - ) - } else { - let result = AuthDataResult(withUser: self, additionalUserInfo: nil) - User.callInMainThreadWithAuthDataResultAndError( - callback: completion, - result: result, - error: nil - ) - } - } - } - #endif - - // Update the new token and refresh user info again. - private func updateTokenAndRefreshUser(idToken: String, refreshToken: String, - accessToken: String?, - expirationDate: Date?, - result: AuthDataResult, - requestConfiguration: AuthRequestConfiguration, - completion: ((AuthDataResult?, Error?) -> Void)?, - withTaskComplete complete: AuthSerialTaskCompletionBlock? = - nil) { - tokenService = SecureTokenService( - withRequestConfiguration: requestConfiguration, - accessToken: idToken, - accessTokenExpirationDate: expirationDate, - refreshToken: refreshToken - ) - internalGetToken { response, error in - if let error { - User.callInMainThreadWithAuthDataResultAndError(callback: completion, - complete: complete, - error: error) - return - } - guard let accessToken else { - fatalError("Internal Auth Error: nil access Token") - } - let getAccountInfoRequest = GetAccountInfoRequest(accessToken: accessToken, - requestConfiguration: requestConfiguration) - Task { - do { - let response = try await AuthBackend.call(with: getAccountInfoRequest) - self.isAnonymous = false - self.update(withGetAccountInfoResponse: response) - if let error = self.updateKeychain() { - User.callInMainThreadWithAuthDataResultAndError( - callback: completion, - complete: complete, - error: error - ) - return - } - User.callInMainThreadWithAuthDataResultAndError(callback: completion, complete: complete, - result: result) - } catch { - self.signOutIfTokenIsInvalid(withError: error) - User.callInMainThreadWithAuthDataResultAndError(callback: completion, error: error) - } - } - } - } - /// 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 || @@ -1817,36 +1037,21 @@ extension User: NSSecureCoding {} } } - /// Retrieves the Firebase authentication token, possibly refreshing it if it has expired. - /// - Parameter callback: The block to invoke when the token is available. Invoked asynchronously - /// 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) - callback(nil, error) - return - } + func internalGetToken(forceRefresh: Bool = false) async throws -> String { + do { + let (token, tokenUpdated) = try await tokenService.fetchAccessToken( + user: self, + forcingRefresh: forceRefresh + ) if tokenUpdated { - if let error = self.updateKeychain() { - callback(nil, error) - return - } - } - callback(token, nil) - } - } - - 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!) + if let error = updateKeychain() { + throw error } } + return token! + } catch { + signOutIfTokenIsInvalid(withError: error) + throw error } } @@ -1856,50 +1061,6 @@ extension User: NSSecureCoding {} return auth?.updateKeychain(withUser: self) } - /// Calls a callback in main thread with error. - /// - Parameter callback: The callback to be called in main thread. - /// - Parameter error: The error to pass to callback. - - class func callInMainThreadWithError(callback: ((Error?) -> Void)?, error: Error?) { - if let callback { - DispatchQueue.main.async { - callback(error) - } - } - } - - /// Calls a callback in main thread with user and error. - /// - Parameter callback: The callback to be called in main thread. - /// - Parameter user: The user to pass to callback if there is no error. - /// - Parameter error: The error to pass to callback. - private class func callInMainThreadWithUserAndError(callback: ((User?, Error?) -> Void)?, - user: User, - error: Error?) { - if let callback { - DispatchQueue.main.async { - callback((error != nil) ? nil : user, error) - } - } - } - - /// Calls a callback in main thread with user and error. - /// - Parameter callback: The callback to be called in main thread. - 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) - } - } - } - // MARK: NSSecureCoding private let kUserIDCodingKey = "userID" diff --git a/FirebaseAuth/Sources/Swift/User/UserProfileChangeRequest.swift b/FirebaseAuth/Sources/Swift/User/UserProfileChangeRequest.swift index 493f3d80f92..9d58beec72d 100644 --- a/FirebaseAuth/Sources/Swift/User/UserProfileChangeRequest.swift +++ b/FirebaseAuth/Sources/Swift/User/UserProfileChangeRequest.swift @@ -24,13 +24,11 @@ import Foundation @objc open var displayName: String? { get { return _displayName } set(newDisplayName) { - kAuthGlobalWorkQueue.async { - if self.consumed { - fatalError("Internal Auth Error: Invalid call to setDisplayName after commitChanges.") - } - self.displayNameWasSet = true - self._displayName = newDisplayName + if consumed { + fatalError("Internal Auth Error: Invalid call to setDisplayName after commitChanges.") } + displayNameWasSet = true + _displayName = newDisplayName } } @@ -40,13 +38,11 @@ import Foundation @objc open var photoURL: URL? { get { return _photoURL } set(newPhotoURL) { - kAuthGlobalWorkQueue.async { - if self.consumed { - fatalError("Internal Auth Error: Invalid call to setPhotoURL after commitChanges.") - } - self.photoURLWasSet = true - self._photoURL = newPhotoURL + if consumed { + fatalError("Internal Auth Error: Invalid call to setPhotoURL after commitChanges.") } + photoURLWasSet = true + _photoURL = newPhotoURL } } @@ -56,47 +52,20 @@ import Foundation /// /// Invoked asynchronously on the main thread in the future. /// - /// This method should only be called once.Once called, property values should not be changed. + /// This method should only be called once. Once called, property values should not be changed. /// - Parameter completion: Optionally; the block invoked when the user profile change has been /// applied. @objc open func commitChanges(completion: ((Error?) -> Void)? = nil) { - kAuthGlobalWorkQueue.async { - if self.consumed { - fatalError("Internal Auth Error: commitChanges should only be called once.") - } - self.consumed = true - // Return fast if there is nothing to update: - if !self.photoURLWasSet, !self.displayNameWasSet { - User.callInMainThreadWithError(callback: completion, error: nil) - return - } - let displayName = self.displayName - let displayNameWasSet = self.displayNameWasSet - let photoURL = self.photoURL - let photoURLWasSet = self.photoURLWasSet - - self.user.executeUserUpdateWithChanges(changeBlock: { user, request in - if photoURLWasSet { - request.photoURL = photoURL - } - if displayNameWasSet { - request.displayName = displayName + Task { + do { + try await self.commitChanges() + await MainActor.run { + completion?(nil) } - }) { error in - if let error { - User.callInMainThreadWithError(callback: completion, error: error) - return + } catch { + await MainActor.run { + completion?(error) } - if displayNameWasSet { - self.user.displayName = displayName - } - if photoURLWasSet { - self.user.photoURL = photoURL - } - if let error = self.user.updateKeychain() { - User.callInMainThreadWithError(callback: completion, error: error) - } - User.callInMainThreadWithError(callback: completion, error: nil) } } } @@ -106,23 +75,15 @@ import Foundation /// This method should only be called once. Once called, property values should not be changed. @available(iOS 13, tvOS 13, macOS 10.15, macCatalyst 13, watchOS 7, *) open func commitChanges() async throws { - return try await withCheckedThrowingContinuation { continuation in - self.commitChanges { error in - if let error { - continuation.resume(throwing: error) - } else { - continuation.resume() - } - } - } + try await user.auth.authWorker.commitChanges(changeRequest: self) } init(_ user: User) { self.user = user } - private let user: User - private var consumed = false - private var displayNameWasSet = false - private var photoURLWasSet = false + let user: User + var consumed = false + var displayNameWasSet = false + var photoURLWasSet = false } diff --git a/FirebaseAuth/Tests/Unit/AuthDispatcherTests.swift b/FirebaseAuth/Tests/Unit/AuthDispatcherTests.swift deleted file mode 100644 index 08f34585ba0..00000000000 --- a/FirebaseAuth/Tests/Unit/AuthDispatcherTests.swift +++ /dev/null @@ -1,72 +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 AuthDispatcherTests: XCTestCase { - let kTestDelay = 0.1 - let kMaxDifferenceBetweenTimeIntervals = 0.4 - - /** @fn testSharedInstance - @brief Tests @c sharedInstance returns the same object. - */ - func testSharedInstance() { - let instance1 = AuthDispatcher.shared - let instance2 = AuthDispatcher.shared - XCTAssert(instance1 === instance2) - } - - /** @fn testDispatchAfterDelay - @brief Tests @c dispatchAfterDelay indeed dispatches the specified task after the provided - delay. - */ - func testDispatchAfterDelay() { - let dispatcher = AuthDispatcher.shared - let testWorkQueue = DispatchQueue(label: "test.work.queue") - let expectation = self.expectation(description: #function) - let dateBeforeDispatch = Date() - dispatcher.dispatchAfterImplementation = nil - dispatcher.dispatch(afterDelay: kTestDelay, queue: testWorkQueue) { [self] in - let timeSinceDispatch = fabs(dateBeforeDispatch.timeIntervalSinceNow - self.kTestDelay) - XCTAssertLessThan(timeSinceDispatch, kMaxDifferenceBetweenTimeIntervals) - expectation.fulfill() - } - waitForExpectations(timeout: 5) - } - - /** @fn testSetDispatchAfterImplementation - @brief Tests that @c dispatchAfterImplementation indeed configures a custom implementation for - @c dispatchAfterDelay. - */ - func testSetDispatchAfterImplementation() { - let dispatcher = AuthDispatcher.shared - let testWorkQueue = DispatchQueue(label: "test.work.queue") - let expectation = self.expectation(description: #function) - dispatcher.dispatchAfterImplementation = { delay, queue, task in - XCTAssertEqual(self.kTestDelay, delay) - XCTAssertEqual(testWorkQueue, queue) - expectation.fulfill() - } - - dispatcher.dispatch(afterDelay: kTestDelay, queue: testWorkQueue) { - // Fail to ensure this code is never executed. - XCTFail("Should not execute this code") - } - waitForExpectations(timeout: 5) - } -} diff --git a/FirebaseAuth/Tests/Unit/AuthTests.swift b/FirebaseAuth/Tests/Unit/AuthTests.swift index 1167bf22ca2..49fac3d1e48 100644 --- a/FirebaseAuth/Tests/Unit/AuthTests.swift +++ b/FirebaseAuth/Tests/Unit/AuthTests.swift @@ -49,25 +49,6 @@ class AuthTests: RPCBaseTests { app: FirebaseApp.app(name: name)!, keychainStorageProvider: keychainStorageProvider ) - - // Set authDispatcherCallback implementation in order to save the token refresh task for later - // execution. - AuthDispatcher.shared.dispatchAfterImplementation = { delay, queue, task in - XCTAssertNotNil(task) - XCTAssertGreaterThan(delay, 0) - XCTAssertEqual(kAuthGlobalWorkQueue, queue) - self.authDispatcherCallback = task - } - // Wait until Auth initialization completes - waitForAuthGlobalWorkQueueDrain() - } - - private func waitForAuthGlobalWorkQueueDrain() { - let workerSemaphore = DispatchSemaphore(value: 0) - kAuthGlobalWorkQueue.async { - workerSemaphore.signal() - } - _ = workerSemaphore.wait(timeout: DispatchTime.distantFuture) } /** @fn testFetchSignInMethodsForEmailSuccess @@ -507,6 +488,25 @@ class AuthTests: RPCBaseTests { waitForExpectations(timeout: 5) } + /** @fn testResetPasswordSuccessAsync + @brief Tests the flow of a successful @c confirmPasswordResetWithCode:newPassword: call. + */ + func testResetPasswordSuccessAsync() async throws { + // 1. Setup respond block to test and fake send request. + rpcIssuer.respondBlock = { + // 2. Validate the created Request instance. + let request = try XCTUnwrap(self.rpcIssuer.request as? ResetPasswordRequest) + XCTAssertEqual(request.oobCode, self.kFakeOobCode) + XCTAssertEqual(request.updatedPassword, self.kFakePassword) + XCTAssertEqual(request.apiKey, AuthTests.kFakeAPIKey) + + // 3. Send the response from the fake backend. + try self.rpcIssuer.respond(withJSON: [:]) + } + try await auth?.signOut() + try await auth?.confirmPasswordReset(withCode: kFakeOobCode, newPassword: kFakePassword) + } + /** @fn testResetPasswordFailure @brief Tests the flow of a failed @c confirmPasswordResetWithCode:newPassword:completion: call. @@ -1899,6 +1899,34 @@ class AuthTests: RPCBaseTests { waitForExpectations(timeout: 5) } + /** @fn testUpdateCurrentUserSuccessAsync + @brief Tests the flow of a successful @c updateCurrentUser:completion: + call with a network error. + */ + func testUpdateCurrentUserSuccessAsync() async throws { + // Sign in with the first user. + try await waitForSignInWithAccessTokenAsync() + let auth = try XCTUnwrap(auth) + let user1 = try XCTUnwrap(auth.currentUser) + let kTestAPIKey = "fakeAPIKey" + user1.requestConfiguration = AuthRequestConfiguration(apiKey: kTestAPIKey, + appID: kTestFirebaseAppID) + try await auth.signOut() + + let kTestAccessToken2 = "fakeAccessToken2" + try await waitForSignInWithAccessTokenAsync(fakeAccessToken: kTestAccessToken2) + let user2 = auth.currentUser + + // Current user should now be user2. + XCTAssertEqual(auth.currentUser, user2) + + try await auth.updateCurrentUser(user1) + + // Current user should now be user1. + XCTAssertEqual(auth.currentUser, user1) + XCTAssertNotEqual(auth.currentUser, user2) + } + /** @fn testRevokeTokenSuccess @brief Tests the flow of a successful @c revokeToken:completion. */ @@ -1957,6 +1985,19 @@ class AuthTests: RPCBaseTests { XCTAssertNil(auth.currentUser) } + /** @fn testSignOutAsync + @brief Tests the @c signOut: method. + */ + func testSignOutAsync() throws { + try waitForSignInWithAccessToken() + Task { + // Verify signing out succeeds and clears the current user. + let auth = try XCTUnwrap(auth) + try await auth.signOut() + XCTAssertNil(auth.currentUser) + } + } + /** @fn testIsSignInWithEmailLink @brief Tests the @c isSignInWithEmailLink: method. */ @@ -2099,6 +2140,18 @@ class AuthTests: RPCBaseTests { #endif } + /** @fn testUseEmulatorAsync + @brief Tests the @c useEmulatorWithHost:port: method. + */ + func testUseEmulatorAsync() async throws { + await auth.useEmulator(withHost: "host", port: 12345) + XCTAssertEqual("host:12345", auth.requestConfiguration.emulatorHostAndPort) + #if os(iOS) + let settings = try XCTUnwrap(auth.settings) + XCTAssertTrue(settings.isAppVerificationDisabledForTesting) + #endif + } + /** @fn testUseEmulatorNeverCalled @brief Tests that the emulatorHostAndPort stored in @c FIRAuthRequestConfiguration is nil if the @c useEmulatorWithHost:port: is not called. @@ -2131,6 +2184,7 @@ class AuthTests: RPCBaseTests { func testAutomaticTokenRefresh() throws { try auth.signOut() // Enable auto refresh + auth.fastTokenRefreshForTest = true enableAutoTokenRefresh() // Sign in a user. @@ -2142,19 +2196,11 @@ class AuthTests: RPCBaseTests { // refresh. XCTAssertEqual(AuthTests.kAccessToken, auth.currentUser?.rawAccessToken()) - // Execute saved token refresh task. - let expectation = self.expectation(description: #function) - kAuthGlobalWorkQueue.async { - XCTAssertNotNil(self.authDispatcherCallback) - self.authDispatcherCallback?() - expectation.fulfill() - } - waitForExpectations(timeout: 5) - waitForAuthGlobalWorkQueueDrain() + // Wait for automatic token refresh to execute saved token refresh task. + sleep(1) // Verify that current user's access token is the "new" access token provided in the mock secure // token response during automatic token refresh. - RPCBaseTests.waitSleep() XCTAssertEqual(AuthTests.kNewAccessToken, auth.currentUser?.rawAccessToken()) } @@ -2165,6 +2211,7 @@ class AuthTests: RPCBaseTests { func testAutomaticTokenRefreshInvalidTokenFailure() throws { try auth.signOut() // Enable auto refresh + auth.fastTokenRefreshForTest = true enableAutoTokenRefresh() // Sign in a user. @@ -2177,18 +2224,10 @@ class AuthTests: RPCBaseTests { // refresh. XCTAssertEqual(AuthTests.kAccessToken, auth.currentUser?.rawAccessToken()) - // Execute saved token refresh task. - let expectation = self.expectation(description: #function) - kAuthGlobalWorkQueue.async { - XCTAssertNotNil(self.authDispatcherCallback) - self.authDispatcherCallback?() - expectation.fulfill() - } - waitForExpectations(timeout: 5) - waitForAuthGlobalWorkQueueDrain() + // Wait for automatic token refresh to execute saved token refresh task. + sleep(1) // Verify that the user is nil after failed attempt to refresh tokens caused signed out. - RPCBaseTests.waitSleep() XCTAssertNil(auth.currentUser) } @@ -2200,6 +2239,7 @@ class AuthTests: RPCBaseTests { func testAutomaticTokenRefreshRetry() throws { try auth.signOut() // Enable auto refresh + auth.fastTokenRefreshForTest = true enableAutoTokenRefresh() // Sign in a user. @@ -2208,17 +2248,6 @@ class AuthTests: RPCBaseTests { // Set up expectation for secureToken RPC made by a failed attempt to refresh tokens. rpcIssuer.secureTokenNetworkError = NSError(domain: "ERROR", code: -1) - // Execute saved token refresh task. - let expectation = self.expectation(description: #function) - kAuthGlobalWorkQueue.async { - XCTAssertNotNil(self.authDispatcherCallback) - self.authDispatcherCallback?() - self.authDispatcherCallback = nil - expectation.fulfill() - } - waitForExpectations(timeout: 5) - waitForAuthGlobalWorkQueueDrain() - rpcIssuer.secureTokenNetworkError = nil setFakeSecureTokenService(fakeAccessToken: AuthTests.kNewAccessToken) @@ -2226,19 +2255,8 @@ class AuthTests: RPCBaseTests { // token (kNewAccessToken). XCTAssertEqual(AuthTests.kAccessToken, auth.currentUser?.rawAccessToken()) - // Execute saved token refresh task. - let expectation2 = self.expectation(description: "dispatchAfterExpectation") - kAuthGlobalWorkQueue.async { - RPCBaseTests.waitSleep() - XCTAssertNotNil(self.authDispatcherCallback) - self.authDispatcherCallback?() - expectation2.fulfill() - } - waitForExpectations(timeout: 5) - waitForAuthGlobalWorkQueueDrain() - - // Time for callback to run. - RPCBaseTests.waitSleep() + // Wait for automatic token refresh to execute saved token refresh task. + sleep(1) // Verify that current user's access token is the "new" access token provided in the mock secure // token response during automatic token refresh. @@ -2250,9 +2268,10 @@ class AuthTests: RPCBaseTests { @brief Tests that app foreground notification triggers the scheduling of an automatic token refresh task. */ - func testAutoRefreshAppForegroundedNotification() throws { + func SKIPtestAutoRefreshAppForegroundedNotification() throws { try auth.signOut() // Enable auto refresh + auth.fastTokenRefreshForTest = true enableAutoTokenRefresh() // Sign in a user. @@ -2267,18 +2286,8 @@ class AuthTests: RPCBaseTests { // token refresh. XCTAssertEqual(AuthTests.kAccessToken, auth.currentUser?.rawAccessToken()) - // Execute saved token refresh task. - let expectation = self.expectation(description: #function) - kAuthGlobalWorkQueue.async { - XCTAssertNotNil(self.authDispatcherCallback) - self.authDispatcherCallback?() - expectation.fulfill() - } - waitForExpectations(timeout: 5) - waitForAuthGlobalWorkQueueDrain() - - // Time for callback to run. - RPCBaseTests.waitSleep() + // Wait for automatic token refresh to execute saved token refresh task. + sleep(1) // Verify that current user's access token is the "new" access token provided in the mock // secure token response during automatic token refresh. @@ -2301,11 +2310,31 @@ class AuthTests: RPCBaseTests { } } let apnsToken = Data() - auth.tokenManager = FakeAuthTokenManager(withApplication: UIApplication.shared) + auth.tokenManagerInit(FakeAuthTokenManager(withApplication: UIApplication.shared)) auth.application(UIApplication.shared, didRegisterForRemoteNotificationsWithDeviceToken: apnsToken) - XCTAssertEqual(auth.tokenManager.token?.data, apnsToken) - XCTAssertEqual(auth.tokenManager.token?.type, .unknown) + XCTAssertEqual(auth.tokenManagerGet().token?.data, apnsToken) + XCTAssertEqual(auth.tokenManagerGet().token?.type, .unknown) + } + + func testAppDidRegisterForRemoteNotifications_APNSTokenUpdatedAsync() async { + class FakeAuthTokenManager: AuthAPNSTokenManager { + override var token: AuthAPNSToken? { + get { + return tokenStore + } + set(setToken) { + tokenStore = setToken + } + } + } + let apnsToken = Data() + await auth.tokenManagerInit(FakeAuthTokenManager(withApplication: UIApplication.shared)) + await auth.application(UIApplication.shared, + didRegisterForRemoteNotificationsWithDeviceToken: apnsToken) + let manager = await auth.tokenManagerGet() + XCTAssertEqual(manager.token?.data, apnsToken) + XCTAssertEqual(manager.token?.type, .unknown) } func testAppDidFailToRegisterForRemoteNotifications_TokenManagerCancels() { @@ -2317,13 +2346,29 @@ class AuthTests: RPCBaseTests { } let error = NSError(domain: "AuthTests", code: -1) let fakeTokenManager = FakeAuthTokenManager(withApplication: UIApplication.shared) - auth.tokenManager = fakeTokenManager + auth.tokenManagerInit(fakeTokenManager) XCTAssertFalse(fakeTokenManager.cancelled) auth.application(UIApplication.shared, didFailToRegisterForRemoteNotificationsWithError: error) XCTAssertTrue(fakeTokenManager.cancelled) } + func testAppDidFailToRegisterForRemoteNotifications_TokenManagerCancelsAsync() async { + class FakeAuthTokenManager: AuthAPNSTokenManager { + var cancelled = false + override func cancel(withError error: Error) { + cancelled = true + } + } + let error = NSError(domain: "AuthTests", code: -1) + let fakeTokenManager = await FakeAuthTokenManager(withApplication: UIApplication.shared) + await auth.tokenManagerInit(fakeTokenManager) + XCTAssertFalse(fakeTokenManager.cancelled) + await auth.application(UIApplication.shared, + didFailToRegisterForRemoteNotificationsWithError: error) + XCTAssertTrue(fakeTokenManager.cancelled) + } + func testAppDidReceiveRemoteNotificationWithCompletion_NotificationManagerHandleCanNotification() { class FakeNotificationManager: AuthNotificationManager { var canHandled = false @@ -2363,6 +2408,23 @@ class AuthTests: RPCBaseTests { XCTAssertTrue(auth.application(UIApplication.shared, open: url, options: [:])) XCTAssertTrue(fakeURLPresenter.canHandled) } + + func testAppOpenURL_AuthPresenterCanHandleURLAsync() async throws { + class FakeURLPresenter: AuthURLPresenter { + var canHandled = false + override func canHandle(url: URL) -> Bool { + canHandled = true + return true + } + } + let url = try XCTUnwrap(URL(string: "https://localhost")) + let fakeURLPresenter = FakeURLPresenter() + auth.authURLPresenter = fakeURLPresenter + XCTAssertFalse(fakeURLPresenter.canHandled) + let result = await auth.application(UIApplication.shared, open: url, options: [:]) + XCTAssertTrue(result) + XCTAssertTrue(fakeURLPresenter.canHandled) + } #endif // os(iOS) // MARK: Interoperability Tests @@ -2430,6 +2492,46 @@ class AuthTests: RPCBaseTests { assertUser(auth?.currentUser) } + private func waitForSignInWithAccessTokenAsync(fakeAccessToken: String = + kAccessToken) async throws { + let kRefreshToken = "fakeRefreshToken" + setFakeGetAccountProvider() + setFakeSecureTokenService() + + // 1. Set up respondBlock to test request and send it to generate a fake response. + rpcIssuer.respondBlock = { + // 2. Validate the created Request instance. + let request = try XCTUnwrap(self.rpcIssuer.request as? VerifyPasswordRequest) + XCTAssertEqual(request.email, self.kEmail) + XCTAssertEqual(request.password, self.kFakePassword) + XCTAssertEqual(request.apiKey, AuthTests.kFakeAPIKey) + XCTAssertTrue(request.returnSecureToken) + + // 3. Send the response from the fake backend. + try self.rpcIssuer.respond(withJSON: ["idToken": fakeAccessToken, + "email": self.kEmail, + "isNewUser": true, + "expiresIn": "3600", + "refreshToken": kRefreshToken]) + } + let authResult = try await auth?.signIn(withEmail: kEmail, password: kFakePassword) + // 4. After the response triggers the callback, verify the returned result. + guard let user = authResult?.user else { + XCTFail("authResult.user is missing") + return + } + XCTAssertEqual(user.refreshToken, kRefreshToken) + XCTAssertFalse(user.isAnonymous) + XCTAssertEqual(user.email, kEmail) + guard let additionalUserInfo = authResult?.additionalUserInfo else { + XCTFail("authResult.additionalUserInfo is missing") + return + } + XCTAssertFalse(additionalUserInfo.isNewUser) + XCTAssertEqual(additionalUserInfo.providerID, EmailAuthProvider.id) + assertUser(auth?.currentUser) + } + private func assertUser(_ user: User?) { guard let user = user else { XCTFail("authResult.additionalUserInfo is missing") diff --git a/FirebaseAuth/Tests/Unit/Fakes/FakeBackendRPCIssuer.swift b/FirebaseAuth/Tests/Unit/Fakes/FakeBackendRPCIssuer.swift index d51ccd255da..0326f7963f4 100644 --- a/FirebaseAuth/Tests/Unit/Fakes/FakeBackendRPCIssuer.swift +++ b/FirebaseAuth/Tests/Unit/Fakes/FakeBackendRPCIssuer.swift @@ -129,7 +129,7 @@ class FakeBackendRPCIssuer: NSObject, AuthBackendRPCIssuer { return } else if let json = fakeSecureTokenServiceJSON { guard let _ = try? respond(withJSON: json) else { - fatalError("fakeGetAccountProviderJSON respond failed") + fatalError("fakeSecureTokenServiceJSON respond failed") } return } diff --git a/FirebaseAuth/Tests/Unit/UserTests.swift b/FirebaseAuth/Tests/Unit/UserTests.swift index 619efac790a..6f995454395 100644 --- a/FirebaseAuth/Tests/Unit/UserTests.swift +++ b/FirebaseAuth/Tests/Unit/UserTests.swift @@ -726,6 +726,14 @@ class UserTests: RPCBaseTests { internalGetIDTokenResult(token: RPCBaseTests.kFakeAccessToken) } + /** @fn testGetIDTokenResultForcingRefreshSameAccessTokenSuccess + @brief Tests the flow of a successful @c getIDTokenResultForcingRefresh:completion: call when + the returned access token is the same as the stored access token. + */ + func testGetIDTokenResultForcingRefreshSameAccessTokenSuccessAsync() async throws { + try await internalGetIDTokenResultAsync(token: RPCBaseTests.kFakeAccessToken) + } + /** @fn testGetIDTokenResultForcingRefreshSuccess @brief Tests the flow successful @c getIDTokenResultForcingRefresh:completion: calls. */ @@ -1579,6 +1587,28 @@ class UserTests: RPCBaseTests { waitForExpectations(timeout: 5) } + private func internalGetIDTokenResultAsync(token: String, forceRefresh: Bool = true, + emailMatch: String = "aunitestuser@gmail.com", + audMatch: String = "test_aud") async throws { + setFakeGetAccountProvider() + let user = try await signInWithEmailPasswordReturnFakeUserAsync(fakeAccessToken: token) + let tokenResult = try await user.getIDTokenResult(forcingRefresh: forceRefresh) + XCTAssertEqual(user.displayName, kDisplayName) + XCTAssertEqual(user.email, kEmail) + XCTAssertEqual(tokenResult.token, token) + XCTAssertNotNil(tokenResult.issuedAtDate) + XCTAssertNotNil(tokenResult.authDate) + XCTAssertNotNil(tokenResult.expirationDate) + XCTAssertNotNil(tokenResult.signInProvider) + + // The lowercased is for the base64 test which seems to be an erroneously uppercased + // "Password"? + XCTAssertEqual(tokenResult.signInProvider.lowercased(), EmailAuthProvider.id) + XCTAssertEqual(tokenResult.claims["email"] as! String, emailMatch) + XCTAssertEqual(tokenResult.claims["aud"] as! String, audMatch) + XCTAssertEqual(tokenResult.signInSecondFactor, "") + } + private func changeUserEmail(user: User, changeEmail: Bool = false, expectation: XCTestExpectation) { do { @@ -1683,6 +1713,40 @@ class UserTests: RPCBaseTests { } } + private func signInWithEmailPasswordReturnFakeUserAsync(fakeAccessToken: String = RPCBaseTests + .kFakeAccessToken) async throws -> User { + let kRefreshToken = "fakeRefreshToken" + setFakeSecureTokenService(fakeAccessToken: fakeAccessToken) + + rpcIssuer?.verifyPasswordRequester = { request in + // 2. Validate the created Request instance. + XCTAssertEqual(request.email, self.kEmail) + XCTAssertEqual(request.password, self.kFakePassword) + XCTAssertEqual(request.apiKey, UserTests.kFakeAPIKey) + XCTAssertTrue(request.returnSecureToken) + do { + // 3. Send the response from the fake backend. + try self.rpcIssuer?.respond(withJSON: ["idToken": fakeAccessToken, + "isNewUser": true, + "refreshToken": kRefreshToken]) + } catch { + XCTFail("Failure sending response: \(error)") + } + } + try await UserTests.auth?.signOut() + let authResult = try await UserTests.auth?.signIn(withEmail: kEmail, password: kFakePassword) + let user = try XCTUnwrap(authResult?.user) + XCTAssertEqual(user.refreshToken, kRefreshToken) + XCTAssertFalse(user.isAnonymous) + XCTAssertEqual(user.email, kEmail) + let additionalUserInfo = try XCTUnwrap(authResult?.additionalUserInfo) + XCTAssertFalse(additionalUserInfo.isNewUser) + XCTAssertEqual(additionalUserInfo.providerID, EmailAuthProvider.id) + // Clear the password Requester to avoid being called again by reauthenticate tests. + rpcIssuer?.verifyPasswordRequester = nil + return user + } + private func signInWithGoogleCredential(completion: @escaping (User) -> Void) { setFakeSecureTokenService(fakeAccessToken: RPCBaseTests.kFakeAccessToken) setFakeGoogleGetAccountProvider()