| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501 |
- // 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 FirebaseCore
- import Foundation
- /// A concrete implementation of `AuthProvider` for phone auth providers.
- ///
- /// This class is available on iOS only.
- @available(iOS 13, tvOS 13, macOS 10.15, macCatalyst 13, watchOS 7, *)
- @objc(FIRPhoneAuthProvider) open class PhoneAuthProvider: NSObject {
- /// A string constant identifying the phone identity provider.
- @objc public static let id = "phone"
- #if os(iOS)
- /// Returns an instance of `PhoneAuthProvider` for the default `Auth` object.
- @objc(provider) open class func provider() -> PhoneAuthProvider {
- return PhoneAuthProvider(auth: Auth.auth())
- }
- /// Returns an instance of `PhoneAuthProvider` for the provided `Auth` object.
- /// - Parameter auth: The auth object to associate with the phone auth provider instance.
- @objc(providerWithAuth:)
- open class func provider(auth: Auth) -> PhoneAuthProvider {
- return PhoneAuthProvider(auth: auth)
- }
- /// Starts the phone number authentication flow by sending a verification code to the
- /// specified phone number.
- ///
- /// Possible error codes:
- /// * `AuthErrorCodeCaptchaCheckFailed` - Indicates that the reCAPTCHA token obtained by
- /// the Firebase Auth is invalid or has expired.
- /// * `AuthErrorCodeQuotaExceeded` - Indicates that the phone verification quota for this
- /// project has been exceeded.
- /// * `AuthErrorCodeInvalidPhoneNumber` - Indicates that the phone number provided is invalid.
- /// * `AuthErrorCodeMissingPhoneNumber` - Indicates that a phone number was not provided.
- /// - Parameter phoneNumber: The phone number to be verified.
- /// - Parameter uiDelegate: An object used to present the SFSafariViewController. The object is
- /// retained by this method until the completion block is executed.
- /// - Parameter completion: The callback to be invoked when the verification flow is finished.
- @objc(verifyPhoneNumber:UIDelegate:completion:)
- open func verifyPhoneNumber(_ phoneNumber: String,
- uiDelegate: AuthUIDelegate? = nil,
- completion: ((_: String?, _: Error?) -> Void)?) {
- verifyPhoneNumber(phoneNumber,
- uiDelegate: uiDelegate,
- multiFactorSession: nil,
- completion: completion)
- }
- /// Verify ownership of the second factor phone number by the current user.
- /// - Parameter phoneNumber: The phone number to be verified.
- /// - Parameter uiDelegate: An object used to present the SFSafariViewController. The object is
- /// retained by this method until the completion block is executed.
- /// - Parameter multiFactorSession: A session to identify the MFA flow. For enrollment, this
- /// identifies the user trying to enroll. For sign-in, this identifies that the user already
- /// passed the first factor challenge.
- /// - Parameter completion: The callback to be invoked when the verification flow is finished.
- @objc(verifyPhoneNumber:UIDelegate:multiFactorSession:completion:)
- open func verifyPhoneNumber(_ phoneNumber: String,
- uiDelegate: AuthUIDelegate? = nil,
- multiFactorSession: MultiFactorSession? = nil,
- completion: ((_: String?, _: Error?) -> Void)?) {
- guard AuthWebUtils.isCallbackSchemeRegistered(forCustomURLScheme: callbackScheme,
- urlTypes: auth.mainBundleUrlTypes) else {
- fatalError(
- "Please register custom URL scheme \(callbackScheme) in the app's Info.plist file."
- )
- }
- kAuthGlobalWorkQueue.async {
- Task {
- do {
- let verificationID = try await self.internalVerify(
- phoneNumber: phoneNumber,
- uiDelegate: uiDelegate,
- multiFactorSession: multiFactorSession
- )
- Auth.wrapMainAsync(callback: completion, withParam: verificationID, error: nil)
- } catch {
- Auth.wrapMainAsync(callback: completion, withParam: nil, error: error)
- }
- }
- }
- }
- /// Verify ownership of the second factor phone number by the current user.
- /// - Parameter phoneNumber: The phone number to be verified.
- /// - Parameter uiDelegate: An object used to present the SFSafariViewController. The object is
- /// retained by this method until the completion block is executed.
- /// - Parameter multiFactorSession: A session to identify the MFA flow. For enrollment, this
- /// identifies the user trying to enroll. For sign-in, this identifies that the user already
- /// passed the first factor challenge.
- /// - Returns: The verification ID
- @available(iOS 13, tvOS 13, macOS 10.15, watchOS 8, *)
- open func verifyPhoneNumber(_ phoneNumber: String,
- uiDelegate: AuthUIDelegate? = nil,
- multiFactorSession: MultiFactorSession? = nil) async throws
- -> String {
- return try await withCheckedThrowingContinuation { continuation in
- self.verifyPhoneNumber(phoneNumber,
- uiDelegate: uiDelegate,
- multiFactorSession: multiFactorSession) { result, error in
- if let error {
- continuation.resume(throwing: error)
- } else if let result {
- continuation.resume(returning: result)
- }
- }
- }
- }
- /// Verify ownership of the second factor phone number by the current user.
- /// - Parameter multiFactorInfo: The phone multi factor whose number need to be verified.
- /// - Parameter uiDelegate: An object used to present the SFSafariViewController. The object is
- /// retained by this method until the completion block is executed.
- /// - Parameter multiFactorSession: A session to identify the MFA flow. For enrollment, this
- /// identifies the user trying to enroll. For sign-in, this identifies that the user already
- /// passed the first factor challenge.
- /// - Parameter completion: The callback to be invoked when the verification flow is finished.
- @objc(verifyPhoneNumberWithMultiFactorInfo:UIDelegate:multiFactorSession:completion:)
- open func verifyPhoneNumber(with multiFactorInfo: PhoneMultiFactorInfo,
- uiDelegate: AuthUIDelegate? = nil,
- multiFactorSession: MultiFactorSession?,
- completion: ((_: String?, _: Error?) -> Void)?) {
- multiFactorSession?.multiFactorInfo = multiFactorInfo
- verifyPhoneNumber(multiFactorInfo.phoneNumber,
- uiDelegate: uiDelegate,
- multiFactorSession: multiFactorSession,
- completion: completion)
- }
- /// Verify ownership of the second factor phone number by the current user.
- /// - Parameter multiFactorInfo: The phone multi factor whose number need to be verified.
- /// - Parameter uiDelegate: An object used to present the SFSafariViewController. The object is
- /// retained by this method until the completion block is executed.
- /// - Parameter multiFactorSession: A session to identify the MFA flow. For enrollment, this
- /// identifies the user trying to enroll. For sign-in, this identifies that the user already
- /// passed the first factor challenge.
- /// - Returns: The verification ID.
- @available(iOS 13, tvOS 13, macOS 10.15, watchOS 8, *)
- open func verifyPhoneNumber(with multiFactorInfo: PhoneMultiFactorInfo,
- uiDelegate: AuthUIDelegate? = nil,
- multiFactorSession: MultiFactorSession?) async throws -> String {
- return try await withCheckedThrowingContinuation { continuation in
- self.verifyPhoneNumber(with: multiFactorInfo,
- uiDelegate: uiDelegate,
- multiFactorSession: multiFactorSession) { result, error in
- if let error {
- continuation.resume(throwing: error)
- } else if let result {
- continuation.resume(returning: result)
- }
- }
- }
- }
- /// Creates an `AuthCredential` for the phone number provider identified by the
- /// verification ID and verification code.
- ///
- /// - Parameter verificationID: The verification ID obtained from invoking
- /// verifyPhoneNumber:completion:
- /// - Parameter verificationCode: The verification code obtained from the user.
- /// - Returns: The corresponding phone auth credential for the verification ID and verification
- /// code provided.
- @objc(credentialWithVerificationID:verificationCode:)
- open func credential(withVerificationID verificationID: String,
- verificationCode: String) -> PhoneAuthCredential {
- return PhoneAuthCredential(withProviderID: PhoneAuthProvider.id,
- verificationID: verificationID,
- verificationCode: verificationCode)
- }
- private func internalVerify(phoneNumber: String,
- uiDelegate: AuthUIDelegate?,
- multiFactorSession: MultiFactorSession? = nil) async throws
- -> String? {
- guard phoneNumber.count > 0 else {
- throw AuthErrorUtils.missingPhoneNumberError(message: nil)
- }
- guard let manager = auth.notificationManager else {
- throw AuthErrorUtils.notificationNotForwardedError()
- }
- guard await manager.checkNotificationForwarding() else {
- throw AuthErrorUtils.notificationNotForwardedError()
- }
- return try await verifyClAndSendVerificationCode(toPhoneNumber: phoneNumber,
- retryOnInvalidAppCredential: true,
- multiFactorSession: multiFactorSession,
- uiDelegate: uiDelegate)
- }
- /// Starts the flow to verify the client via silent push notification.
- /// - Parameter retryOnInvalidAppCredential: Whether or not the flow should be retried if an
- /// AuthErrorCodeInvalidAppCredential error is returned from the backend.
- /// - Parameter phoneNumber: The phone number to be verified.
- /// - Parameter callback: The callback to be invoked on the global work queue when the flow is
- /// finished.
- private func verifyClAndSendVerificationCode(toPhoneNumber phoneNumber: String,
- retryOnInvalidAppCredential: Bool,
- uiDelegate: AuthUIDelegate?) async throws
- -> String? {
- let codeIdentity = try await verifyClient(withUIDelegate: uiDelegate)
- let request = SendVerificationCodeRequest(phoneNumber: phoneNumber,
- codeIdentity: codeIdentity,
- requestConfiguration: auth
- .requestConfiguration)
- do {
- let response = try await AuthBackend.call(with: request)
- return response.verificationID
- } catch {
- return try await handleVerifyErrorWithRetry(error: error,
- phoneNumber: phoneNumber,
- retryOnInvalidAppCredential: retryOnInvalidAppCredential,
- multiFactorSession: nil,
- uiDelegate: uiDelegate)
- }
- }
- /// Starts the flow to verify the client via silent push notification.
- /// - Parameter retryOnInvalidAppCredential: Whether of not the flow should be retried if an
- /// AuthErrorCodeInvalidAppCredential error is returned from the backend.
- /// - Parameter phoneNumber: The phone number to be verified.
- private func verifyClAndSendVerificationCode(toPhoneNumber phoneNumber: String,
- retryOnInvalidAppCredential: Bool,
- multiFactorSession session: MultiFactorSession?,
- uiDelegate: AuthUIDelegate?) async throws
- -> String? {
- if let settings = auth.settings,
- settings.isAppVerificationDisabledForTesting {
- let request = SendVerificationCodeRequest(
- phoneNumber: phoneNumber,
- codeIdentity: CodeIdentity.empty,
- requestConfiguration: auth.requestConfiguration
- )
- let response = try await AuthBackend.call(with: request)
- return response.verificationID
- }
- guard let session else {
- return try await verifyClAndSendVerificationCode(
- toPhoneNumber: phoneNumber,
- retryOnInvalidAppCredential: retryOnInvalidAppCredential,
- uiDelegate: uiDelegate
- )
- }
- let codeIdentity = try await verifyClient(withUIDelegate: uiDelegate)
- let startMFARequestInfo = AuthProtoStartMFAPhoneRequestInfo(phoneNumber: phoneNumber,
- codeIdentity: codeIdentity)
- do {
- if let idToken = session.idToken {
- let request = StartMFAEnrollmentRequest(idToken: idToken,
- enrollmentInfo: startMFARequestInfo,
- requestConfiguration: auth.requestConfiguration)
- let response = try await AuthBackend.call(with: request)
- return response.phoneSessionInfo?.sessionInfo
- } else {
- let request = StartMFASignInRequest(MFAPendingCredential: session.mfaPendingCredential,
- MFAEnrollmentID: session.multiFactorInfo?.uid,
- signInInfo: startMFARequestInfo,
- requestConfiguration: auth.requestConfiguration)
- let response = try await AuthBackend.call(with: request)
- return response.responseInfo?.sessionInfo
- }
- } catch {
- return try await handleVerifyErrorWithRetry(
- error: error,
- phoneNumber: phoneNumber,
- retryOnInvalidAppCredential: retryOnInvalidAppCredential,
- multiFactorSession: session,
- uiDelegate: uiDelegate
- )
- }
- }
- private func handleVerifyErrorWithRetry(error: Error,
- phoneNumber: String,
- retryOnInvalidAppCredential: Bool,
- multiFactorSession session: MultiFactorSession?,
- uiDelegate: AuthUIDelegate?) async throws -> String? {
- if (error as NSError).code == AuthErrorCode.invalidAppCredential.rawValue {
- if retryOnInvalidAppCredential {
- auth.appCredentialManager.clearCredential()
- return try await verifyClAndSendVerificationCode(toPhoneNumber: phoneNumber,
- retryOnInvalidAppCredential: false,
- multiFactorSession: session,
- uiDelegate: uiDelegate)
- }
- throw AuthErrorUtils.unexpectedResponse(deserializedResponse: nil, underlyingError: error)
- }
- throw error
- }
- /// Continues the flow to verify the client via silent push notification.
- private func verifyClient(withUIDelegate uiDelegate: AuthUIDelegate?) async throws
- -> CodeIdentity {
- // Remove the simulator check below after FCM supports APNs in simulators
- #if targetEnvironment(simulator)
- let environment = ProcessInfo().environment
- if environment["XCTestConfigurationFilePath"] == nil {
- return try await CodeIdentity
- .recaptcha(reCAPTCHAFlowWithUIDelegate(withUIDelegate: uiDelegate))
- }
- #endif
- if let credential = auth.appCredentialManager.credential {
- return CodeIdentity.credential(credential)
- }
- var token: AuthAPNSToken
- do {
- token = try await auth.tokenManager.getToken()
- } catch {
- return try await CodeIdentity
- .recaptcha(reCAPTCHAFlowWithUIDelegate(withUIDelegate: uiDelegate))
- }
- let request = VerifyClientRequest(withAppToken: token.string,
- isSandbox: token.type == AuthAPNSTokenType.sandbox,
- requestConfiguration: auth.requestConfiguration)
- do {
- let verifyResponse = try await AuthBackend.call(with: request)
- guard let receipt = verifyResponse.receipt,
- let timeout = verifyResponse.suggestedTimeOutDate?.timeIntervalSinceNow else {
- fatalError("Internal Auth Error: invalid VerifyClientResponse")
- }
- let credential = await
- auth.appCredentialManager.didStartVerification(withReceipt: receipt, timeout: timeout)
- if credential.secret == nil {
- AuthLog.logWarning(code: "I-AUT000014", message: "Failed to receive remote " +
- "notification to verify app identity within \(timeout) " +
- "second(s), falling back to reCAPTCHA verification.")
- return try await CodeIdentity
- .recaptcha(reCAPTCHAFlowWithUIDelegate(withUIDelegate: uiDelegate))
- }
- return CodeIdentity.credential(credential)
- } catch {
- let nserror = error as NSError
- // reCAPTCHA Flow if it's an invalid app credential or a missing app token.
- guard nserror.code == AuthErrorCode.invalidAppCredential.rawValue || nserror
- .code == AuthErrorCode.missingAppToken.rawValue else {
- throw error
- }
- return try await CodeIdentity
- .recaptcha(reCAPTCHAFlowWithUIDelegate(withUIDelegate: uiDelegate))
- }
- }
- /// Continues the flow to verify the client via silent push notification.
- private func reCAPTCHAFlowWithUIDelegate(withUIDelegate uiDelegate: AuthUIDelegate?) async throws
- -> String {
- let eventID = AuthWebUtils.randomString(withLength: 10)
- guard let url = try await reCAPTCHAURL(withEventID: eventID) else {
- fatalError(
- "Internal error: reCAPTCHAURL returned neither a value nor an error. Report issue"
- )
- }
- let callbackMatcher: (URL?) -> Bool = { callbackURL in
- AuthWebUtils.isExpectedCallbackURL(
- callbackURL,
- eventID: eventID,
- authType: self.kAuthTypeVerifyApp,
- callbackScheme: self.callbackScheme
- )
- }
- return try await withCheckedThrowingContinuation { continuation in
- self.auth.authURLPresenter.present(url,
- uiDelegate: uiDelegate,
- callbackMatcher: callbackMatcher) { callbackURL, error in
- if let error {
- continuation.resume(throwing: error)
- } else {
- do {
- try continuation.resume(returning: self.reCAPTCHAToken(forURL: callbackURL))
- } catch {
- continuation.resume(throwing: error)
- }
- }
- }
- }
- }
- /// Parses the reCAPTCHA URL and returns the reCAPTCHA token.
- /// - Parameter url: The url to be parsed for a reCAPTCHA token.
- /// - Returns: The reCAPTCHA token if successful.
- private func reCAPTCHAToken(forURL url: URL?) throws -> String {
- guard let url = url else {
- let reason = "Internal Auth Error: nil URL trying to access RECAPTCHA token"
- throw AuthErrorUtils.appVerificationUserInteractionFailure(reason: reason)
- }
- let actualURLComponents = URLComponents(url: url, resolvingAgainstBaseURL: false)
- if let queryItems = actualURLComponents?.queryItems,
- let deepLinkURL = AuthWebUtils.queryItemValue(name: "deep_link_id", from: queryItems) {
- let deepLinkComponents = URLComponents(string: deepLinkURL)
- if let queryItems = deepLinkComponents?.queryItems {
- if let token = AuthWebUtils.queryItemValue(name: "recaptchaToken", from: queryItems) {
- return token
- }
- if let firebaseError = AuthWebUtils.queryItemValue(
- name: "firebaseError",
- from: queryItems
- ) {
- if let errorData = firebaseError.data(using: .utf8) {
- var errorDict: [AnyHashable: Any]?
- do {
- errorDict = try JSONSerialization.jsonObject(with: errorData) as? [AnyHashable: Any]
- } catch {
- throw AuthErrorUtils.JSONSerializationError(underlyingError: error)
- }
- if let errorDict,
- let code = errorDict["code"] as? String,
- let message = errorDict["message"] as? String {
- throw AuthErrorUtils.urlResponseError(code: code, message: message)
- }
- }
- }
- }
- let reason = "An unknown error occurred with the following response: \(deepLinkURL)"
- throw AuthErrorUtils.appVerificationUserInteractionFailure(reason: reason)
- }
- let reason = "Failed to get url Components for url: \(url)"
- throw AuthErrorUtils.appVerificationUserInteractionFailure(reason: reason)
- }
- /// Constructs a URL used for opening a reCAPTCHA app verification flow using a given event ID.
- /// - Parameter eventID: The event ID used for this purpose.
- private func reCAPTCHAURL(withEventID eventID: String) async throws -> URL? {
- let authDomain = try await AuthWebUtils
- .fetchAuthDomain(withRequestConfiguration: auth.requestConfiguration)
- let bundleID = Bundle.main.bundleIdentifier
- let clientID = auth.app?.options.clientID
- let appID = auth.app?.options.googleAppID
- let apiKey = auth.requestConfiguration.apiKey
- let appCheck = auth.requestConfiguration.appCheck
- var queryItems = [URLQueryItem(name: "apiKey", value: apiKey),
- URLQueryItem(name: "authType", value: kAuthTypeVerifyApp),
- URLQueryItem(name: "ibi", value: bundleID ?? ""),
- URLQueryItem(name: "v", value: AuthBackend.authUserAgent()),
- URLQueryItem(name: "eventId", value: eventID)]
- if usingClientIDScheme {
- queryItems.append(URLQueryItem(name: "clientId", value: clientID))
- } else {
- queryItems.append(URLQueryItem(name: "appId", value: appID))
- }
- if let languageCode = auth.requestConfiguration.languageCode {
- queryItems.append(URLQueryItem(name: "hl", value: languageCode))
- }
- var components = URLComponents(string: "https://\(authDomain)/__/auth/handler?")
- components?.queryItems = queryItems
- if let appCheck {
- let tokenResult = await appCheck.getToken(forcingRefresh: false)
- if let error = tokenResult.error {
- AuthLog.logWarning(code: "I-AUT000018",
- message: "Error getting App Check token; using placeholder " +
- "token instead. Error: \(error)")
- }
- let appCheckTokenFragment = "fac=\(tokenResult.token)"
- components?.fragment = appCheckTokenFragment
- }
- return components?.url
- }
- private let auth: Auth
- private let callbackScheme: String
- private let usingClientIDScheme: Bool
- init(auth: Auth) {
- self.auth = auth
- if let clientID = auth.app?.options.clientID {
- let reverseClientIDScheme = clientID.components(separatedBy: ".").reversed()
- .joined(separator: ".")
- if AuthWebUtils.isCallbackSchemeRegistered(forCustomURLScheme: reverseClientIDScheme,
- urlTypes: auth.mainBundleUrlTypes) {
- callbackScheme = reverseClientIDScheme
- usingClientIDScheme = true
- return
- }
- }
- usingClientIDScheme = false
- if let appID = auth.app?.options.googleAppID {
- let dashedAppID = appID.replacingOccurrences(of: ":", with: "-")
- callbackScheme = "app-\(dashedAppID)"
- return
- }
- callbackScheme = ""
- }
- private let kAuthTypeVerifyApp = "verifyApp"
- #endif
- }
|