| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607 |
- // 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 FirebaseCore
- /**
- @brief 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 {
- @objc public static let id = "phone"
- #if os(iOS)
- /**
- @brief Returns an instance of `PhoneAuthProvider` for the default `Auth` object.
- */
- @objc(provider) public class func provider() -> PhoneAuthProvider {
- return PhoneAuthProvider(auth: Auth.auth())
- }
- /**
- @brief Returns an instance of `PhoneAuthProvider` for the provided `Auth` object.
- @param auth The auth object to associate with the phone auth provider instance.
- */
- @objc(providerWithAuth:)
- public class func provider(auth: Auth) -> PhoneAuthProvider {
- return PhoneAuthProvider(auth: auth)
- }
- // TODO: review/remove public objc
- /**
- @brief Starts the phone number authentication flow by sending a verification code to the
- specified phone number.
- @param phoneNumber The phone number to be verified.
- @param uiDelegate An object used to present the SFSafariViewController. The object is retained
- by this method until the completion block is executed.
- @param completion The callback to be invoked when the verification flow is finished.
- @remarks 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.
- */
- @objc(verifyPhoneNumber:UIDelegate:completion:)
- public func verifyPhoneNumber(_ phoneNumber: String,
- uiDelegate: AuthUIDelegate?,
- 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 {
- let callbackOnMainThread: (String?, Error?) -> Void = { verificationID, error in
- if let completion {
- DispatchQueue.main.async {
- completion(verificationID, error)
- }
- }
- }
- self.internalVerify(phoneNumber: phoneNumber,
- uiDelegate: uiDelegate) { verificationID, error in
- if let error {
- callbackOnMainThread(nil, error)
- } else {
- callbackOnMainThread(verificationID, nil)
- }
- }
- }
- }
- /**
- @brief Verify ownership of the second factor phone number by the current user.
- @param phoneNumber The phone number to be verified.
- @param uiDelegate An object used to present the SFSafariViewController. The object is retained
- by this method until the completion block is executed.
- @param 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.
- @param completion The callback to be invoked when the verification flow is finished.
- */
- @objc(verifyPhoneNumber:UIDelegate:multiFactorSession:completion:)
- public func verifyPhoneNumber(_ phoneNumber: String,
- uiDelegate: AuthUIDelegate?,
- multiFactorSession session: MultiFactorSession? = nil,
- completion: ((_: String?, _: Error?) -> Void)?) {
- guard let session else {
- verifyPhoneNumber(phoneNumber, uiDelegate: uiDelegate, completion: completion)
- return
- }
- 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 {
- let callbackOnMainThread: (String?, Error?) -> Void = { verificationID, error in
- if let completion {
- DispatchQueue.main.async {
- completion(verificationID, error)
- }
- }
- }
- self.internalVerify(phoneNumber: phoneNumber,
- uiDelegate: uiDelegate,
- multiFactorSession: session) { verificationID, error in
- if let error {
- callbackOnMainThread(nil, error)
- } else {
- callbackOnMainThread(verificationID, nil)
- }
- }
- }
- }
- @available(iOS 13, tvOS 13, macOS 10.15, watchOS 8, *)
- public func verifyPhoneNumber(_ phoneNumber: String,
- uiDelegate: AuthUIDelegate?,
- 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)
- }
- }
- }
- }
- /**
- @brief Verify ownership of the second factor phone number by the current user.
- @param multiFactorInfo The phone multi factor whose number need to be verified.
- @param uiDelegate An object used to present the SFSafariViewController. The object is retained
- by this method until the completion block is executed.
- @param 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.
- @param completion The callback to be invoked when the verification flow is finished.
- */
- @objc(verifyPhoneNumberWithMultiFactorInfo:UIDelegate:multiFactorSession:completion:)
- public func verifyPhoneNumber(with multiFactorInfo: PhoneMultiFactorInfo,
- uiDelegate: AuthUIDelegate?,
- multiFactorSession session: MultiFactorSession?,
- completion: ((_: String?, _: Error?) -> Void)?) {
- session?.multiFactorInfo = multiFactorInfo
- verifyPhoneNumber(multiFactorInfo.phoneNumber,
- uiDelegate: uiDelegate,
- multiFactorSession: session,
- completion: completion)
- }
- @available(iOS 13, tvOS 13, macOS 10.15, watchOS 8, *)
- public func verifyPhoneNumber(with multiFactorInfo: PhoneMultiFactorInfo,
- uiDelegate: AuthUIDelegate?,
- 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)
- }
- }
- }
- }
- /**
- @brief Creates an `AuthCredential` for the phone number provider identified by the
- verification ID and verification code.
- @param verificationID The verification ID obtained from invoking
- verifyPhoneNumber:completion:
- @param verificationCode The verification code obtained from the user.
- @return The corresponding phone auth credential for the verification ID and verification code
- provided.
- */
- @objc(credentialWithVerificationID:verificationCode:)
- public 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 session: MultiFactorSession? = nil,
- completion: @escaping ((String?, Error?) -> Void)) {
- guard phoneNumber.count > 0 else {
- completion(nil, AuthErrorUtils.missingPhoneNumberError(message: nil))
- return
- }
- auth.notificationManager.checkNotificationForwarding { isNotificationBeingForwarded in
- guard isNotificationBeingForwarded else {
- completion(nil, AuthErrorUtils.notificationNotForwardedError())
- return
- }
- self.verifyClAndSendVerificationCode(toPhoneNumber: phoneNumber,
- retryOnInvalidAppCredential: true,
- multiFactorSession: session,
- uiDelegate: uiDelegate) { verificationID, error in
- completion(verificationID, error)
- }
- }
- }
- /** @fn
- @brief Starts the flow to verify the client via silent push notification.
- @param retryOnInvalidAppCredential Whether of not the flow should be retried if an
- AuthErrorCodeInvalidAppCredential error is returned from the backend.
- @param phoneNumber The phone number to be verified.
- @param 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?,
- callback: @escaping (String?, Error?) -> Void) {
- verifyClient(withUIDelegate: uiDelegate) { appCredential, reCAPTCHAToken, error in
- if let error {
- callback(nil, error)
- return
- }
- var request: SendVerificationCodeRequest?
- if let appCredential {
- request = SendVerificationCodeRequest(phoneNumber: phoneNumber,
- appCredential: appCredential,
- reCAPTCHAToken: nil,
- requestConfiguration: self.auth
- .requestConfiguration)
- } else if let reCAPTCHAToken {
- request = SendVerificationCodeRequest(phoneNumber: phoneNumber,
- appCredential: nil,
- reCAPTCHAToken: reCAPTCHAToken,
- requestConfiguration: self.auth
- .requestConfiguration)
- } else {
- fatalError("Internal Phone Auth Error:Both reCAPTCHA token and app credential are nil")
- }
- if let request {
- AuthBackend.post(with: request) { response, error in
- if let error {
- self.handleVerifyErrorWithRetry(error: error,
- phoneNumber: phoneNumber,
- retryOnInvalidAppCredential: retryOnInvalidAppCredential,
- multiFactorSession: nil,
- uiDelegate: uiDelegate,
- callback: callback)
- return
- }
- callback(response?.verificationID, nil)
- }
- }
- }
- }
- /** @fn
- @brief Starts the flow to verify the client via silent push notification.
- @param retryOnInvalidAppCredential Whether of not the flow should be retried if an
- AuthErrorCodeInvalidAppCredential error is returned from the backend.
- @param phoneNumber The phone number to be verified.
- @param callback The callback to be invoked on the global work queue when the flow is
- finished.
- */
- private func verifyClAndSendVerificationCode(toPhoneNumber phoneNumber: String,
- retryOnInvalidAppCredential: Bool,
- multiFactorSession session: MultiFactorSession?,
- uiDelegate: AuthUIDelegate?,
- callback: @escaping (String?, Error?) -> Void) {
- if let settings = auth.settings,
- settings.isAppVerificationDisabledForTesting {
- let request = SendVerificationCodeRequest(
- phoneNumber: phoneNumber,
- appCredential: nil,
- reCAPTCHAToken: nil,
- requestConfiguration: auth.requestConfiguration
- )
- AuthBackend.post(with: request) { response, error in
- callback(response?.verificationID, error)
- }
- return
- }
- guard let session else {
- verifyClAndSendVerificationCode(toPhoneNumber: phoneNumber,
- retryOnInvalidAppCredential: retryOnInvalidAppCredential,
- uiDelegate: uiDelegate,
- callback: callback)
- return
- }
- verifyClient(withUIDelegate: uiDelegate) { appCredential, reCAPTCHAToken, error in
- if let error {
- callback(nil, error)
- return
- }
- let startMFARequestInfo = AuthProtoStartMFAPhoneRequestInfo(phoneNumber: phoneNumber,
- appCredential: appCredential,
- reCAPTCHAToken: reCAPTCHAToken)
- // XXX TODO: Figure out the right logic here, where we're assuming the callback is a certain
- // type.
- let request: any AuthRPCRequest = (session.idToken != nil) ?
- StartMFAEnrollmentRequest(idToken: session.idToken,
- enrollmentInfo: startMFARequestInfo,
- requestConfiguration: self.auth.requestConfiguration) :
- StartMFASignInRequest(MFAPendingCredential: session.mfaPendingCredential,
- MFAEnrollmentID: session.multiFactorInfo?.uid,
- signInInfo: startMFARequestInfo,
- requestConfiguration: self.auth.requestConfiguration)
- AuthBackend.post(with: request) { response, error in
- if let error {
- self.handleVerifyErrorWithRetry(error: error,
- phoneNumber: phoneNumber,
- retryOnInvalidAppCredential: retryOnInvalidAppCredential,
- multiFactorSession: session,
- uiDelegate: uiDelegate,
- callback: callback)
- return
- }
- callback((response as? StartMFAEnrollmentResponse)?.enrollmentResponse?.sessionInfo, nil)
- }
- }
- }
- private func handleVerifyErrorWithRetry(error: Error,
- phoneNumber: String,
- retryOnInvalidAppCredential: Bool,
- multiFactorSession session: MultiFactorSession?,
- uiDelegate: AuthUIDelegate?,
- callback: @escaping (String?, Error?) -> Void) {
- if (error as NSError).code == AuthErrorCode.invalidAppCredential.rawValue {
- if retryOnInvalidAppCredential {
- auth.appCredentialManager.clearCredential()
- verifyClAndSendVerificationCode(toPhoneNumber: phoneNumber,
- retryOnInvalidAppCredential: false,
- multiFactorSession: session,
- uiDelegate: uiDelegate,
- callback: callback)
- return
- }
- callback(nil, AuthErrorUtils.unexpectedResponse(deserializedResponse: nil,
- underlyingError: error))
- return
- }
- callback(nil, error)
- }
- /** @fn
- @brief Continues the flow to verify the client via silent push notification.
- @param completion The callback to be invoked when the client verification flow is finished.
- */
- private func verifyClient(withUIDelegate uiDelegate: AuthUIDelegate?,
- completion: @escaping (AuthAppCredential?, String?, Error?) -> Void) {
- // Remove the simulator check below after FCM supports APNs in simulators
- #if targetEnvironment(simulator)
- let environment = ProcessInfo().environment
- if environment["XCTestConfigurationFilePath"] == nil {
- reCAPTCHAFlowWithUIDelegate(withUIDelegate: uiDelegate, completion: completion)
- return
- }
- #endif
- if let credential = auth.appCredentialManager.credential {
- completion(credential, nil, nil)
- return
- }
- auth.tokenManager.getToken { token, error in
- guard let token else {
- self.reCAPTCHAFlowWithUIDelegate(withUIDelegate: uiDelegate, completion: completion)
- return
- }
- let request = VerifyClientRequest(withAppToken: token.string,
- isSandbox: token.type == AuthAPNSTokenType.sandbox,
- requestConfiguration: self.auth.requestConfiguration)
- AuthBackend.post(with: request) { response, error in
- if let error {
- let nserror = error as NSError
- // reCAPTCHA Flow if it's an invalid app credential or a missing app token.
- if (nserror.code == AuthErrorCode.internalError.rawValue &&
- (nserror.userInfo[NSUnderlyingErrorKey] as? NSError)?.code ==
- AuthErrorCode.invalidAppCredential.rawValue) ||
- nserror.code == AuthErrorCode.missingAppToken.rawValue {
- self.reCAPTCHAFlowWithUIDelegate(withUIDelegate: uiDelegate, completion: completion)
- return
- } else {
- completion(nil, nil, error)
- return
- }
- }
- guard let verifyResponse = response,
- let receipt = verifyResponse.receipt,
- let timeout = verifyResponse.suggestedTimeOutDate?.timeIntervalSinceNow else {
- fatalError("Internal Auth Error: invalid VerifyClientResponse")
- }
- self.auth.appCredentialManager.didStartVerification(withReceipt: receipt,
- timeout: timeout) { credential in
- 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.")
- self.reCAPTCHAFlowWithUIDelegate(withUIDelegate: uiDelegate, completion: completion)
- return
- }
- completion(credential, nil, nil)
- }
- }
- }
- }
- /** @fn
- @brief Continues the flow to verify the client via silent push notification.
- @param completion The callback to be invoked when the client verification flow is finished.
- */
- private func reCAPTCHAFlowWithUIDelegate(withUIDelegate uiDelegate: AuthUIDelegate?,
- completion: @escaping (AuthAppCredential?, String?,
- Error?) -> Void) {
- let eventID = AuthWebUtils.randomString(withLength: 10)
- reCAPTCHAURL(withEventID: eventID) { reCAPTCHAURL, error in
- if let error = error {
- completion(nil, nil, error)
- return
- }
- guard let reCAPTCHAURL = reCAPTCHAURL 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
- )
- }
- self.auth.authURLPresenter.present(reCAPTCHAURL,
- uiDelegate: uiDelegate,
- callbackMatcher: callbackMatcher) { callbackURL, error in
- if let error = error {
- completion(nil, nil, error)
- return
- }
- do {
- let reCAPTHAtoken = try self.reCAPTCHAToken(forURL: callbackURL)
- completion(nil, reCAPTHAtoken, nil)
- } catch {
- completion(nil, nil, error)
- }
- }
- }
- }
- /**
- @brief Parses the reCAPTCHA URL and returns the reCAPTCHA token.
- @param URL The url to be parsed for a reCAPTCHA token.
- @param error The error that occurred if any.
- @return 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)
- }
- /** @fn
- @brief Constructs a URL used for opening a reCAPTCHA app verification flow using a given event
- ID.
- @param eventID The event ID used for this purpose.
- @param completion The callback invoked after the URL has been constructed or an error
- has been encountered.
- */
- private func reCAPTCHAURL(withEventID eventID: String,
- completion: @escaping ((URL?, Error?) -> Void)) {
- AuthWebUtils
- .fetchAuthDomain(withRequestConfiguration: auth.requestConfiguration) { authDomain, error in
- if let error = error {
- completion(nil, error)
- return
- }
- if let authDomain = authDomain {
- let bundleID = Bundle.main.bundleIdentifier
- let clientID = self.auth.app?.options.clientID
- let appID = self.auth.app?.options.googleAppID
- let apiKey = self.auth.requestConfiguration.apiKey
- let appCheck = self.auth.requestConfiguration.appCheck
- var queryItems = [URLQueryItem(name: "apiKey", value: apiKey),
- URLQueryItem(name: "authType", value: self.kAuthTypeVerifyApp),
- URLQueryItem(name: "ibi", value: bundleID ?? ""),
- URLQueryItem(name: "v", value: AuthBackend.authUserAgent()),
- URLQueryItem(name: "eventId", value: eventID)]
- if self.usingClientIDScheme {
- queryItems.append(URLQueryItem(name: "clientId", value: clientID))
- } else {
- queryItems.append(URLQueryItem(name: "appId", value: appID))
- }
- if let languageCode = self.auth.requestConfiguration.languageCode {
- queryItems.append(URLQueryItem(name: "hl", value: languageCode))
- }
- var components = URLComponents(string: "https://\(authDomain)/__/auth/handler?")
- components?.queryItems = queryItems
- if let appCheck {
- appCheck.getToken(forcingRefresh: false) { tokenResult in
- if let error = tokenResult.error {
- AuthLog.logWarning(code: "I-AUT000018",
- message: "Error getting App Check token; using placeholder " +
- "token instead. Error: \(error)")
- }
- let appCheckTokenFragment = "fac=\(tokenResult.token)"
- components?.fragment = appCheckTokenFragment
- completion(components?.url, nil)
- }
- } else {
- completion(components?.url, nil)
- }
- }
- }
- }
- private let auth: Auth
- private let callbackScheme: String
- private let usingClientIDScheme: Bool
- private 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
- }
|