PhoneAuthProvider.swift 32 KB

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