PhoneAuthProvider.swift 32 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673
  1. // Copyright 2023 Google LLC
  2. //
  3. // Licensed under the Apache License, Version 2.0 (the "License");
  4. // you may not use this file except in compliance with the License.
  5. // You may obtain a copy of the License at
  6. //
  7. // http://www.apache.org/licenses/LICENSE-2.0
  8. //
  9. // Unless required by applicable law or agreed to in writing, software
  10. // distributed under the License is distributed on an "AS IS" BASIS,
  11. // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  12. // See the License for the specific language governing permissions and
  13. // limitations under the License.
  14. import FirebaseCore
  15. import Foundation
  16. /// A concrete implementation of `AuthProvider` for phone auth providers.
  17. ///
  18. /// This class is available on iOS only.
  19. @available(iOS 13, tvOS 13, macOS 10.15, macCatalyst 13, watchOS 7, *)
  20. @objc(FIRPhoneAuthProvider) open class PhoneAuthProvider: NSObject,
  21. @unchecked Sendable /* TODO: sendable */ {
  22. /// A string constant identifying the phone identity provider.
  23. @objc public static let id = "phone"
  24. private static let recaptchaVersion = "RECAPTCHA_ENTERPRISE"
  25. private static let clientType = "CLIENT_TYPE_IOS"
  26. private static let fakeCaptchaResponse = "NO_RECAPTCHA"
  27. #if os(iOS)
  28. /// Returns an instance of `PhoneAuthProvider` for the default `Auth` object.
  29. @objc(provider) open class func provider() -> PhoneAuthProvider {
  30. return PhoneAuthProvider(auth: Auth.auth())
  31. }
  32. /// Returns an instance of `PhoneAuthProvider` for the provided `Auth` object.
  33. /// - Parameter auth: The auth object to associate with the phone auth provider instance.
  34. @objc(providerWithAuth:)
  35. open class func provider(auth: Auth) -> PhoneAuthProvider {
  36. return PhoneAuthProvider(auth: auth)
  37. }
  38. /// Starts the phone number authentication flow by sending a verification code to the
  39. /// specified phone number.
  40. ///
  41. /// Possible error codes:
  42. /// * `AuthErrorCodeCaptchaCheckFailed` - Indicates that the reCAPTCHA token obtained by
  43. /// the Firebase Auth is invalid or has expired.
  44. /// * `AuthErrorCodeQuotaExceeded` - Indicates that the phone verification quota for this
  45. /// project has been exceeded.
  46. /// * `AuthErrorCodeInvalidPhoneNumber` - Indicates that the phone number provided is invalid.
  47. /// * `AuthErrorCodeMissingPhoneNumber` - Indicates that a phone number was not provided.
  48. /// - Parameter phoneNumber: The phone number to be verified.
  49. /// - Parameter uiDelegate: An object used to present the SFSafariViewController. The object is
  50. /// retained by this method until the completion block is executed.
  51. /// - Parameter completion: The callback to be invoked when the verification flow is finished.
  52. @objc(verifyPhoneNumber:UIDelegate:completion:)
  53. open func verifyPhoneNumber(_ phoneNumber: String,
  54. uiDelegate: AuthUIDelegate? = nil,
  55. completion: (@Sendable (_: String?, _: Error?) -> Void)?) {
  56. verifyPhoneNumber(phoneNumber,
  57. uiDelegate: uiDelegate,
  58. multiFactorSession: nil,
  59. completion: completion)
  60. }
  61. /// Verify ownership of the second factor phone number by the current user.
  62. /// - Parameter phoneNumber: The phone number to be verified.
  63. /// - Parameter uiDelegate: An object used to present the SFSafariViewController. The object is
  64. /// retained by this method until the completion block is executed.
  65. /// - Parameter multiFactorSession: A session to identify the MFA flow. For enrollment, this
  66. /// identifies the user trying to enroll. For sign-in, this identifies that the user already
  67. /// passed the first factor challenge.
  68. /// - Parameter completion: The callback to be invoked when the verification flow is finished.
  69. @objc(verifyPhoneNumber:UIDelegate:multiFactorSession:completion:)
  70. open func verifyPhoneNumber(_ phoneNumber: String,
  71. uiDelegate: AuthUIDelegate? = nil,
  72. multiFactorSession: MultiFactorSession? = nil,
  73. completion: (@Sendable (String?, Error?) -> Void)?) {
  74. Task {
  75. do {
  76. let verificationID = try await verifyPhoneNumber(
  77. phoneNumber,
  78. uiDelegate: uiDelegate,
  79. multiFactorSession: multiFactorSession
  80. )
  81. await MainActor.run {
  82. completion?(verificationID, nil)
  83. }
  84. } catch {
  85. await MainActor.run {
  86. completion?(nil, error)
  87. }
  88. }
  89. }
  90. }
  91. /// Verify ownership of the second factor phone number by the current user.
  92. /// - Parameter phoneNumber: The phone number to be verified.
  93. /// - Parameter uiDelegate: An object used to present the SFSafariViewController. The object is
  94. /// retained by this method until the completion block is executed.
  95. /// - Parameter multiFactorSession: A session to identify the MFA flow. For enrollment, this
  96. /// identifies the user trying to enroll. For sign-in, this identifies that the user already
  97. /// passed the first factor challenge.
  98. /// - Returns: The verification ID
  99. @available(iOS 13, tvOS 13, macOS 10.15, watchOS 8, *)
  100. open func verifyPhoneNumber(_ phoneNumber: String,
  101. uiDelegate: AuthUIDelegate? = nil,
  102. multiFactorSession: MultiFactorSession? = nil) async throws
  103. -> String {
  104. guard AuthWebUtils.isCallbackSchemeRegistered(forCustomURLScheme: callbackScheme,
  105. urlTypes: auth.mainBundleUrlTypes) else {
  106. fatalError(
  107. "Please register custom URL scheme \(callbackScheme) in the app's Info.plist file."
  108. )
  109. }
  110. if let verificationID = try await internalVerify(phoneNumber: phoneNumber,
  111. uiDelegate: uiDelegate,
  112. multiFactorSession: multiFactorSession) {
  113. return verificationID
  114. } else {
  115. throw AuthErrorUtils.invalidVerificationIDError(message: "Invalid verification ID")
  116. }
  117. }
  118. /// Verify ownership of the second factor phone number by the current user.
  119. /// - Parameter multiFactorInfo: The phone multi factor whose number need to be verified.
  120. /// - Parameter uiDelegate: An object used to present the SFSafariViewController. The object is
  121. /// retained by this method until the completion block is executed.
  122. /// - Parameter multiFactorSession: A session to identify the MFA flow. For enrollment, this
  123. /// identifies the user trying to enroll. For sign-in, this identifies that the user already
  124. /// passed the first factor challenge.
  125. /// - Parameter completion: The callback to be invoked when the verification flow is finished.
  126. @objc(verifyPhoneNumberWithMultiFactorInfo:UIDelegate:multiFactorSession:completion:)
  127. open func verifyPhoneNumber(with multiFactorInfo: PhoneMultiFactorInfo,
  128. uiDelegate: AuthUIDelegate? = nil,
  129. multiFactorSession: MultiFactorSession?,
  130. completion: (@Sendable (String?, Error?) -> Void)?) {
  131. Task {
  132. do {
  133. let verificationID = try await verifyPhoneNumber(
  134. with: multiFactorInfo,
  135. uiDelegate: uiDelegate,
  136. multiFactorSession: multiFactorSession
  137. )
  138. await MainActor.run {
  139. completion?(verificationID, nil)
  140. }
  141. } catch {
  142. await MainActor.run {
  143. completion?(nil, error)
  144. }
  145. }
  146. }
  147. }
  148. /// Verify ownership of the second factor phone number by the current user.
  149. /// - Parameter multiFactorInfo: The phone multi factor whose number need to be verified.
  150. /// - Parameter uiDelegate: An object used to present the SFSafariViewController. The object is
  151. /// retained by this method until the completion block is executed.
  152. /// - Parameter multiFactorSession: A session to identify the MFA flow. For enrollment, this
  153. /// identifies the user trying to enroll. For sign-in, this identifies that the user already
  154. /// passed the first factor challenge.
  155. /// - Returns: The verification ID.
  156. @available(iOS 13, tvOS 13, macOS 10.15, watchOS 8, *)
  157. open func verifyPhoneNumber(with multiFactorInfo: PhoneMultiFactorInfo,
  158. uiDelegate: AuthUIDelegate? = nil,
  159. multiFactorSession: MultiFactorSession?) async throws -> String {
  160. multiFactorSession?.multiFactorInfo = multiFactorInfo
  161. return try await verifyPhoneNumber(multiFactorInfo.phoneNumber,
  162. uiDelegate: uiDelegate,
  163. multiFactorSession: multiFactorSession)
  164. }
  165. /// Creates an `AuthCredential` for the phone number provider identified by the
  166. /// verification ID and verification code.
  167. ///
  168. /// - Parameter verificationID: The verification ID obtained from invoking
  169. /// verifyPhoneNumber:completion:
  170. /// - Parameter verificationCode: The verification code obtained from the user.
  171. /// - Returns: The corresponding phone auth credential for the verification ID and verification
  172. /// code provided.
  173. @objc(credentialWithVerificationID:verificationCode:)
  174. open func credential(withVerificationID verificationID: String,
  175. verificationCode: String) -> PhoneAuthCredential {
  176. return PhoneAuthCredential(withProviderID: PhoneAuthProvider.id,
  177. verificationID: verificationID,
  178. verificationCode: verificationCode)
  179. }
  180. private func internalVerify(phoneNumber: String,
  181. uiDelegate: AuthUIDelegate?,
  182. multiFactorSession: MultiFactorSession? = nil) async throws
  183. -> String? {
  184. guard !phoneNumber.isEmpty else {
  185. throw AuthErrorUtils.missingPhoneNumberError(message: nil)
  186. }
  187. guard let manager = auth.notificationManager else {
  188. throw AuthErrorUtils.notificationNotForwardedError()
  189. }
  190. guard await manager.checkNotificationForwarding() else {
  191. throw AuthErrorUtils.notificationNotForwardedError()
  192. }
  193. let recaptchaVerifier = AuthRecaptchaVerifier.shared(auth: auth)
  194. if let settings = auth.settings,
  195. settings.isAppVerificationDisabledForTesting {
  196. // If app verification is disabled for testing
  197. // do not fetch recaptcha config, as this is not implemented in emulator
  198. // Treat this same as RCE enable status off
  199. return try await verifyClAndSendVerificationCode(
  200. toPhoneNumber: phoneNumber,
  201. retryOnInvalidAppCredential: true,
  202. multiFactorSession: multiFactorSession,
  203. uiDelegate: uiDelegate
  204. )
  205. }
  206. try await recaptchaVerifier.retrieveRecaptchaConfig(forceRefresh: true)
  207. switch recaptchaVerifier.enablementStatus(forProvider: .phone) {
  208. case .off:
  209. return try await verifyClAndSendVerificationCode(
  210. toPhoneNumber: phoneNumber,
  211. retryOnInvalidAppCredential: true,
  212. multiFactorSession: multiFactorSession,
  213. uiDelegate: uiDelegate
  214. )
  215. case .audit:
  216. return try await verifyClAndSendVerificationCodeWithRecaptcha(
  217. toPhoneNumber: phoneNumber,
  218. retryOnInvalidAppCredential: true,
  219. multiFactorSession: multiFactorSession,
  220. uiDelegate: uiDelegate,
  221. recaptchaVerifier: recaptchaVerifier
  222. )
  223. case .enforce:
  224. return try await verifyClAndSendVerificationCodeWithRecaptcha(
  225. toPhoneNumber: phoneNumber,
  226. retryOnInvalidAppCredential: false,
  227. multiFactorSession: multiFactorSession,
  228. uiDelegate: uiDelegate,
  229. recaptchaVerifier: recaptchaVerifier
  230. )
  231. }
  232. }
  233. func verifyClAndSendVerificationCodeWithRecaptcha(toPhoneNumber phoneNumber: String,
  234. retryOnInvalidAppCredential: Bool,
  235. uiDelegate: AuthUIDelegate?,
  236. recaptchaVerifier: AuthRecaptchaVerifier) async throws
  237. -> String? {
  238. let request = SendVerificationCodeRequest(phoneNumber: phoneNumber,
  239. codeIdentity: CodeIdentity.empty,
  240. requestConfiguration: auth
  241. .requestConfiguration)
  242. do {
  243. try await recaptchaVerifier.injectRecaptchaFields(
  244. request: request,
  245. provider: .phone,
  246. action: .sendVerificationCode
  247. )
  248. let response = try await auth.backend.call(with: request)
  249. return response.verificationID
  250. } catch {
  251. return try await handleVerifyErrorWithRetry(error: error,
  252. phoneNumber: phoneNumber,
  253. retryOnInvalidAppCredential: retryOnInvalidAppCredential,
  254. multiFactorSession: nil,
  255. uiDelegate: uiDelegate,
  256. auditFallback: true)
  257. }
  258. }
  259. /// Starts the flow to verify the client via silent push notification.
  260. /// - Parameter retryOnInvalidAppCredential: Whether or not the flow should be retried if an
  261. /// AuthErrorCodeInvalidAppCredential error is returned from the backend.
  262. /// - Parameter phoneNumber: The phone number to be verified.
  263. /// - Parameter callback: The callback to be invoked on the global work queue when the flow is
  264. /// finished.
  265. private func verifyClAndSendVerificationCode(toPhoneNumber phoneNumber: String,
  266. retryOnInvalidAppCredential: Bool,
  267. uiDelegate: AuthUIDelegate?,
  268. auditFallback: Bool = false) async throws
  269. -> String? {
  270. let codeIdentity = try await verifyClient(withUIDelegate: uiDelegate)
  271. let request = SendVerificationCodeRequest(phoneNumber: phoneNumber,
  272. codeIdentity: codeIdentity,
  273. requestConfiguration: auth
  274. .requestConfiguration)
  275. if auditFallback {
  276. request.injectRecaptchaFields(
  277. recaptchaResponse: PhoneAuthProvider.fakeCaptchaResponse,
  278. recaptchaVersion: PhoneAuthProvider.recaptchaVersion
  279. )
  280. }
  281. do {
  282. let response = try await auth.backend.call(with: request)
  283. return response.verificationID
  284. } catch {
  285. return try await handleVerifyErrorWithRetry(
  286. error: error,
  287. phoneNumber: phoneNumber,
  288. retryOnInvalidAppCredential: retryOnInvalidAppCredential,
  289. multiFactorSession: nil,
  290. uiDelegate: uiDelegate,
  291. auditFallback: auditFallback
  292. )
  293. }
  294. }
  295. /// Starts the flow to verify the client via silent push notification. This is used in both
  296. /// .Audit and .Enforce mode
  297. /// - Parameter retryOnInvalidAppCredential: Whether or not the flow should be retried if an
  298. /// AuthErrorCodeInvalidAppCredential error is returned from the backend.
  299. /// - Parameter phoneNumber: The phone number to be verified.
  300. private func verifyClAndSendVerificationCodeWithRecaptcha(toPhoneNumber phoneNumber: String,
  301. retryOnInvalidAppCredential: Bool,
  302. multiFactorSession session: MultiFactorSession?,
  303. uiDelegate: AuthUIDelegate?,
  304. recaptchaVerifier: AuthRecaptchaVerifier) async throws
  305. -> String? {
  306. if let settings = auth.settings,
  307. settings.isAppVerificationDisabledForTesting {
  308. let request = SendVerificationCodeRequest(
  309. phoneNumber: phoneNumber,
  310. codeIdentity: CodeIdentity.empty,
  311. requestConfiguration: auth.requestConfiguration
  312. )
  313. let response = try await auth.backend.call(with: request)
  314. return response.verificationID
  315. }
  316. guard let session else {
  317. return try await verifyClAndSendVerificationCodeWithRecaptcha(
  318. toPhoneNumber: phoneNumber,
  319. retryOnInvalidAppCredential: retryOnInvalidAppCredential,
  320. uiDelegate: uiDelegate,
  321. recaptchaVerifier: recaptchaVerifier
  322. )
  323. }
  324. let startMFARequestInfo = AuthProtoStartMFAPhoneRequestInfo(phoneNumber: phoneNumber,
  325. codeIdentity: CodeIdentity.empty)
  326. do {
  327. if let idToken = session.idToken {
  328. let request = StartMFAEnrollmentRequest(idToken: idToken,
  329. enrollmentInfo: startMFARequestInfo,
  330. requestConfiguration: auth.requestConfiguration)
  331. try await recaptchaVerifier.injectRecaptchaFields(
  332. request: request,
  333. provider: .phone,
  334. action: .mfaSmsEnrollment
  335. )
  336. let response = try await auth.backend.call(with: request)
  337. return response.phoneSessionInfo?.sessionInfo
  338. } else {
  339. let request = StartMFASignInRequest(MFAPendingCredential: session.mfaPendingCredential,
  340. MFAEnrollmentID: session.multiFactorInfo?.uid,
  341. signInInfo: startMFARequestInfo,
  342. requestConfiguration: auth.requestConfiguration)
  343. try await recaptchaVerifier.injectRecaptchaFields(
  344. request: request,
  345. provider: .phone,
  346. action: .mfaSmsSignIn
  347. )
  348. let response = try await auth.backend.call(with: request)
  349. return response.responseInfo.sessionInfo
  350. }
  351. } catch {
  352. // For Audit fallback only after rCE check failed
  353. return try await handleVerifyErrorWithRetry(
  354. error: error,
  355. phoneNumber: phoneNumber,
  356. retryOnInvalidAppCredential: retryOnInvalidAppCredential,
  357. multiFactorSession: session,
  358. uiDelegate: uiDelegate,
  359. auditFallback: true
  360. )
  361. }
  362. }
  363. /// Starts the flow to verify the client via silent push notification.
  364. /// This method is called in Audit fallback flow with "NO_RECAPTCHA" fake token and Off flow
  365. /// - Parameter retryOnInvalidAppCredential: Whether or not the flow should be retried if an
  366. /// AuthErrorCodeInvalidAppCredential error is returned from the backend.
  367. /// - Parameter phoneNumber: The phone number to be verified.
  368. private func verifyClAndSendVerificationCode(toPhoneNumber phoneNumber: String,
  369. retryOnInvalidAppCredential: Bool,
  370. multiFactorSession session: MultiFactorSession?,
  371. uiDelegate: AuthUIDelegate?,
  372. auditFallback: Bool = false) async throws
  373. -> String? {
  374. if let settings = auth.settings,
  375. settings.isAppVerificationDisabledForTesting {
  376. let request = SendVerificationCodeRequest(
  377. phoneNumber: phoneNumber,
  378. codeIdentity: CodeIdentity.empty,
  379. requestConfiguration: auth.requestConfiguration
  380. )
  381. let response = try await auth.backend.call(with: request)
  382. return response.verificationID
  383. }
  384. guard let session else {
  385. // Phone MFA flow
  386. return try await verifyClAndSendVerificationCode(
  387. toPhoneNumber: phoneNumber,
  388. retryOnInvalidAppCredential: retryOnInvalidAppCredential,
  389. uiDelegate: uiDelegate,
  390. auditFallback: auditFallback
  391. )
  392. }
  393. // MFA flows
  394. let codeIdentity = try await verifyClient(withUIDelegate: uiDelegate)
  395. let startMFARequestInfo = AuthProtoStartMFAPhoneRequestInfo(phoneNumber: phoneNumber,
  396. codeIdentity: codeIdentity)
  397. if auditFallback {
  398. startMFARequestInfo.injectRecaptchaFields(
  399. recaptchaResponse: PhoneAuthProvider.fakeCaptchaResponse,
  400. recaptchaVersion: PhoneAuthProvider.recaptchaVersion,
  401. clientType: PhoneAuthProvider.clientType
  402. )
  403. }
  404. do {
  405. if let idToken = session.idToken {
  406. let request = StartMFAEnrollmentRequest(idToken: idToken,
  407. enrollmentInfo: startMFARequestInfo,
  408. requestConfiguration: auth.requestConfiguration)
  409. let response = try await auth.backend.call(with: request)
  410. return response.phoneSessionInfo?.sessionInfo
  411. } else {
  412. let request = StartMFASignInRequest(MFAPendingCredential: session.mfaPendingCredential,
  413. MFAEnrollmentID: session.multiFactorInfo?.uid,
  414. signInInfo: startMFARequestInfo,
  415. requestConfiguration: auth.requestConfiguration)
  416. let response = try await auth.backend.call(with: request)
  417. return response.responseInfo.sessionInfo
  418. }
  419. } catch {
  420. return try await handleVerifyErrorWithRetry(
  421. error: error,
  422. phoneNumber: phoneNumber,
  423. retryOnInvalidAppCredential: retryOnInvalidAppCredential,
  424. multiFactorSession: session,
  425. uiDelegate: uiDelegate,
  426. auditFallback: auditFallback
  427. )
  428. }
  429. }
  430. /// This method is only called when Audit failed on rCE on invalid-app-credential exception
  431. private func handleVerifyErrorWithRetry(error: Error,
  432. phoneNumber: String,
  433. retryOnInvalidAppCredential: Bool,
  434. multiFactorSession session: MultiFactorSession?,
  435. uiDelegate: AuthUIDelegate?,
  436. auditFallback: Bool = false) async throws -> String? {
  437. if (error as NSError).code == AuthErrorCode.invalidAppCredential.rawValue {
  438. if retryOnInvalidAppCredential {
  439. auth.appCredentialManager.clearCredential()
  440. return try await verifyClAndSendVerificationCode(toPhoneNumber: phoneNumber,
  441. retryOnInvalidAppCredential: false,
  442. multiFactorSession: session,
  443. uiDelegate: uiDelegate,
  444. auditFallback: auditFallback)
  445. }
  446. throw AuthErrorUtils.unexpectedResponse(deserializedResponse: nil, underlyingError: error)
  447. }
  448. throw error
  449. }
  450. /// Continues the flow to verify the client via silent push notification.
  451. private func verifyClient(withUIDelegate uiDelegate: AuthUIDelegate?) async throws
  452. -> CodeIdentity {
  453. // Remove the simulator check below after FCM supports APNs in simulators
  454. #if targetEnvironment(simulator)
  455. let environment = ProcessInfo().environment
  456. if environment["XCTestConfigurationFilePath"] == nil {
  457. return try await CodeIdentity
  458. .recaptcha(reCAPTCHAFlowWithUIDelegate(withUIDelegate: uiDelegate))
  459. }
  460. #endif
  461. if let credential = auth.appCredentialManager.credential {
  462. return CodeIdentity.credential(credential)
  463. }
  464. var token: AuthAPNSToken
  465. do {
  466. token = try await auth.tokenManager.getToken()
  467. } catch {
  468. return try await CodeIdentity
  469. .recaptcha(reCAPTCHAFlowWithUIDelegate(withUIDelegate: uiDelegate))
  470. }
  471. let request = VerifyClientRequest(withAppToken: token.string,
  472. isSandbox: token.type == AuthAPNSTokenType.sandbox,
  473. requestConfiguration: auth.requestConfiguration)
  474. do {
  475. let verifyResponse = try await auth.backend.call(with: request)
  476. guard let receipt = verifyResponse.receipt,
  477. let timeout = verifyResponse.suggestedTimeOutDate?.timeIntervalSinceNow else {
  478. fatalError("Internal Auth Error: invalid VerifyClientResponse")
  479. }
  480. let credential = await
  481. auth.appCredentialManager.didStartVerification(withReceipt: receipt, timeout: timeout)
  482. if credential.secret == nil {
  483. AuthLog.logWarning(code: "I-AUT000014", message: "Failed to receive remote " +
  484. "notification to verify app identity within \(timeout) " +
  485. "second(s), falling back to reCAPTCHA verification.")
  486. return try await CodeIdentity
  487. .recaptcha(reCAPTCHAFlowWithUIDelegate(withUIDelegate: uiDelegate))
  488. }
  489. return CodeIdentity.credential(credential)
  490. } catch {
  491. let nserror = error as NSError
  492. // reCAPTCHA Flow if it's an invalid app credential or a missing app token.
  493. guard nserror.code == AuthErrorCode.invalidAppCredential.rawValue || nserror
  494. .code == AuthErrorCode.missingAppToken.rawValue else {
  495. throw error
  496. }
  497. return try await CodeIdentity
  498. .recaptcha(reCAPTCHAFlowWithUIDelegate(withUIDelegate: uiDelegate))
  499. }
  500. }
  501. /// Continues the flow to verify the client via silent push notification.
  502. private func reCAPTCHAFlowWithUIDelegate(withUIDelegate uiDelegate: AuthUIDelegate?) async throws
  503. -> String {
  504. let eventID = AuthWebUtils.randomString(withLength: 10)
  505. guard let url = try await reCAPTCHAURL(withEventID: eventID) else {
  506. fatalError(
  507. "Internal error: reCAPTCHAURL returned neither a value nor an error. Report issue"
  508. )
  509. }
  510. let callbackMatcher: @Sendable (URL?) -> Bool = { callbackURL in
  511. AuthWebUtils.isExpectedCallbackURL(
  512. callbackURL,
  513. eventID: eventID,
  514. authType: self.kAuthTypeVerifyApp,
  515. callbackScheme: self.callbackScheme
  516. )
  517. }
  518. return try await withCheckedThrowingContinuation { continuation in
  519. DispatchQueue.main.async {
  520. self.auth.authURLPresenter.present(url,
  521. uiDelegate: uiDelegate,
  522. callbackMatcher: callbackMatcher) { callbackURL, error in
  523. if let error {
  524. continuation.resume(throwing: error)
  525. } else {
  526. do {
  527. try continuation.resume(returning: self.reCAPTCHAToken(forURL: callbackURL))
  528. } catch {
  529. continuation.resume(throwing: error)
  530. }
  531. }
  532. }
  533. }
  534. }
  535. }
  536. /// Parses the reCAPTCHA URL and returns the reCAPTCHA token.
  537. /// - Parameter url: The url to be parsed for a reCAPTCHA token.
  538. /// - Returns: The reCAPTCHA token if successful.
  539. private func reCAPTCHAToken(forURL url: URL?) throws -> String {
  540. guard let url = url else {
  541. let reason = "Internal Auth Error: nil URL trying to access RECAPTCHA token"
  542. throw AuthErrorUtils.appVerificationUserInteractionFailure(reason: reason)
  543. }
  544. let actualURLComponents = URLComponents(url: url, resolvingAgainstBaseURL: false)
  545. if let queryItems = actualURLComponents?.queryItems,
  546. let deepLinkURL = AuthWebUtils.queryItemValue(name: "deep_link_id", from: queryItems) {
  547. let deepLinkComponents = URLComponents(string: deepLinkURL)
  548. if let queryItems = deepLinkComponents?.queryItems {
  549. if let token = AuthWebUtils.queryItemValue(name: "recaptchaToken", from: queryItems) {
  550. return token
  551. }
  552. if let firebaseError = AuthWebUtils.queryItemValue(
  553. name: "firebaseError",
  554. from: queryItems
  555. ) {
  556. if let errorData = firebaseError.data(using: .utf8) {
  557. var errorDict: [AnyHashable: Any]?
  558. do {
  559. errorDict = try JSONSerialization.jsonObject(with: errorData) as? [AnyHashable: Any]
  560. } catch {
  561. throw AuthErrorUtils.JSONSerializationError(underlyingError: error)
  562. }
  563. if let errorDict,
  564. let code = errorDict["code"] as? String,
  565. let message = errorDict["message"] as? String {
  566. throw AuthErrorUtils.urlResponseError(code: code, message: message)
  567. }
  568. }
  569. }
  570. }
  571. let reason = "An unknown error occurred with the following response: \(deepLinkURL)"
  572. throw AuthErrorUtils.appVerificationUserInteractionFailure(reason: reason)
  573. }
  574. let reason = "Failed to get url Components for url: \(url)"
  575. throw AuthErrorUtils.appVerificationUserInteractionFailure(reason: reason)
  576. }
  577. /// Constructs a URL used for opening a reCAPTCHA app verification flow using a given event ID.
  578. /// - Parameter eventID: The event ID used for this purpose.
  579. private func reCAPTCHAURL(withEventID eventID: String) async throws -> URL? {
  580. let authDomain = try await AuthWebUtils
  581. .fetchAuthDomain(withRequestConfiguration: auth.requestConfiguration, backend: auth.backend)
  582. let bundleID = Bundle.main.bundleIdentifier
  583. let clientID = auth.app?.options.clientID
  584. let appID = auth.app?.options.googleAppID
  585. let apiKey = auth.requestConfiguration.apiKey
  586. let appCheck = auth.requestConfiguration.appCheck
  587. var queryItems = [URLQueryItem(name: "apiKey", value: apiKey),
  588. URLQueryItem(name: "authType", value: kAuthTypeVerifyApp),
  589. URLQueryItem(name: "ibi", value: bundleID ?? ""),
  590. URLQueryItem(name: "v", value: AuthBackend.authUserAgent()),
  591. URLQueryItem(name: "eventId", value: eventID)]
  592. if usingClientIDScheme {
  593. queryItems.append(URLQueryItem(name: "clientId", value: clientID))
  594. } else {
  595. queryItems.append(URLQueryItem(name: "appId", value: appID))
  596. }
  597. if let languageCode = auth.requestConfiguration.languageCode {
  598. queryItems.append(URLQueryItem(name: "hl", value: languageCode))
  599. }
  600. var components = URLComponents(string: "https://\(authDomain)/__/auth/handler?")
  601. components?.queryItems = queryItems
  602. if let appCheck {
  603. let tokenResult = await appCheck.getToken(forcingRefresh: false)
  604. if let error = tokenResult.error {
  605. AuthLog.logWarning(code: "I-AUT000018",
  606. message: "Error getting App Check token; using placeholder " +
  607. "token instead. Error: \(error)")
  608. }
  609. let appCheckTokenFragment = "fac=\(tokenResult.token)"
  610. components?.fragment = appCheckTokenFragment
  611. }
  612. return components?.url
  613. }
  614. private let auth: Auth
  615. private let callbackScheme: String
  616. private let usingClientIDScheme: Bool
  617. private var recaptchaVerifier: AuthRecaptchaVerifier?
  618. init(auth: Auth) {
  619. self.auth = auth
  620. if let clientID = auth.app?.options.clientID {
  621. let reverseClientIDScheme = clientID.components(separatedBy: ".").reversed()
  622. .joined(separator: ".")
  623. if AuthWebUtils.isCallbackSchemeRegistered(forCustomURLScheme: reverseClientIDScheme,
  624. urlTypes: auth.mainBundleUrlTypes) {
  625. callbackScheme = reverseClientIDScheme
  626. usingClientIDScheme = true
  627. return
  628. }
  629. }
  630. usingClientIDScheme = false
  631. if let appID = auth.app?.options.googleAppID {
  632. let dashedAppID = appID.replacingOccurrences(of: ":", with: "-")
  633. callbackScheme = "app-\(dashedAppID)"
  634. return
  635. }
  636. callbackScheme = ""
  637. recaptchaVerifier = AuthRecaptchaVerifier.shared(auth: auth)
  638. }
  639. private let kAuthTypeVerifyApp = "verifyApp"
  640. #endif
  641. }