From cf3e90246036a13a3ed3a7c257f130642a3f49c3 Mon Sep 17 00:00:00 2001 From: Paul Beusterien Date: Fri, 20 Dec 2024 17:25:54 -0800 Subject: [PATCH] [auth] Fix handling of cloud blocking function errors (#14280) --- FirebaseAuth/CHANGELOG.md | 1 + .../Sources/Swift/Backend/AuthBackend.swift | 16 +++-- .../Swift/Utilities/AuthErrorUtils.swift | 65 +++++++++++++++---- .../Sources/Swift/Utilities/AuthErrors.swift | 2 +- .../Tests/Unit/AuthBackendTests.swift | 45 +++++++++++++ 5 files changed, 110 insertions(+), 19 deletions(-) diff --git a/FirebaseAuth/CHANGELOG.md b/FirebaseAuth/CHANGELOG.md index 42f0ff36650..bce9975d100 100644 --- a/FirebaseAuth/CHANGELOG.md +++ b/FirebaseAuth/CHANGELOG.md @@ -3,6 +3,7 @@ - [fixed] Updated most decoders to be consistent with Firebase 10's behavior for decoding `nil` values. (#14212) - [fixed] Address Xcode 16.2 concurrency compile time issues. (#14279) +- [fixed] Fix handling of cloud blocking function errors. (#14052) # 11.6.0 - [added] Added reCAPTCHA Enterprise support for app verification during phone diff --git a/FirebaseAuth/Sources/Swift/Backend/AuthBackend.swift b/FirebaseAuth/Sources/Swift/Backend/AuthBackend.swift index 61be56b09ca..2915b8d7044 100644 --- a/FirebaseAuth/Sources/Swift/Backend/AuthBackend.swift +++ b/FirebaseAuth/Sources/Swift/Backend/AuthBackend.swift @@ -293,14 +293,22 @@ final class AuthBackend: AuthBackendProtocol { .unexpectedErrorResponse(deserializedResponse: dictionary, underlyingError: error) } + private static func splitStringAtFirstColon(_ input: String) -> (before: String, after: String) { + guard let colonIndex = input.firstIndex(of: ":") else { + return (input, "") // No colon, return original string before and empty after + } + let before = String(input.prefix(upTo: colonIndex)) + .trimmingCharacters(in: .whitespacesAndNewlines) + let after = String(input.suffix(from: input.index(after: colonIndex))) + .trimmingCharacters(in: .whitespacesAndNewlines) + return (before, after.isEmpty ? "" : after) // Return empty after if it's empty + } + private static func clientError(withServerErrorMessage serverErrorMessage: String, errorDictionary: [String: Any], response: AuthRPCResponse?, error: Error?) -> Error? { - let split = serverErrorMessage.split(separator: ":") - let shortErrorMessage = split.first?.trimmingCharacters(in: .whitespacesAndNewlines) - let serverDetailErrorMessage = String(split.count > 1 ? split[1] : "") - .trimmingCharacters(in: .whitespacesAndNewlines) + let (shortErrorMessage, serverDetailErrorMessage) = splitStringAtFirstColon(serverErrorMessage) switch shortErrorMessage { case "USER_NOT_FOUND": return AuthErrorUtils .userNotFoundError(message: serverDetailErrorMessage) diff --git a/FirebaseAuth/Sources/Swift/Utilities/AuthErrorUtils.swift b/FirebaseAuth/Sources/Swift/Utilities/AuthErrorUtils.swift index ee397bfc670..ff0f9696130 100644 --- a/FirebaseAuth/Sources/Swift/Utilities/AuthErrorUtils.swift +++ b/FirebaseAuth/Sources/Swift/Utilities/AuthErrorUtils.swift @@ -510,25 +510,62 @@ class AuthErrorUtils { return error(code: .accountExistsWithDifferentCredential, userInfo: userInfo) } + private static func extractJSONObjectFromString(from string: String) -> [String: Any]? { + // 1. Find the start of the JSON object. + guard let start = string.firstIndex(of: "{") else { + return nil // No JSON object found + } + // 2. Find the end of the JSON object. + // Start from the first curly brace `{` + var curlyLevel = 0 + var endIndex: String.Index? + + for index in string.indices.suffix(from: start) { + let char = string[index] + if char == "{" { + curlyLevel += 1 + } else if char == "}" { + curlyLevel -= 1 + if curlyLevel == 0 { + endIndex = index + break + } + } + } + guard let end = endIndex else { + return nil // Unbalanced curly braces + } + + // 3. Extract the JSON string. + let jsonString = String(string[start ... end]) + + // 4. Convert JSON String to JSON Object + guard let jsonData = jsonString.data(using: .utf8) else { + return nil // Could not convert String to Data + } + + do { + if let jsonObject = try JSONSerialization + .jsonObject(with: jsonData, options: []) as? [String: Any] { + return jsonObject + } else { + return nil // JSON Object is not a dictionary + } + } catch { + return nil // Failed to deserialize JSON + } + } + static func blockingCloudFunctionServerResponse(message: String?) -> Error { guard let message else { return error(code: .blockingCloudFunctionError, message: message) } - var jsonString = message.replacingOccurrences( - of: "HTTP Cloud Function returned an error:", - with: "" - ) - jsonString = jsonString.trimmingCharacters(in: .whitespaces) - let jsonData = jsonString.data(using: .utf8) ?? Data() - do { - let jsonDict = try JSONSerialization - .jsonObject(with: jsonData, options: []) as? [String: Any] ?? [:] - let errorDict = jsonDict["error"] as? [String: Any] ?? [:] - let errorMessage = errorDict["message"] as? String - return error(code: .blockingCloudFunctionError, message: errorMessage) - } catch { - return JSONSerializationError(underlyingError: error) + guard let jsonDict = extractJSONObjectFromString(from: message) else { + return error(code: .blockingCloudFunctionError, message: message) } + let errorDict = jsonDict["error"] as? [String: Any] ?? [:] + let errorMessage = errorDict["message"] as? String + return error(code: .blockingCloudFunctionError, message: errorMessage) } #if os(iOS) diff --git a/FirebaseAuth/Sources/Swift/Utilities/AuthErrors.swift b/FirebaseAuth/Sources/Swift/Utilities/AuthErrors.swift index 7f73876e89c..d4a8a2595f8 100644 --- a/FirebaseAuth/Sources/Swift/Utilities/AuthErrors.swift +++ b/FirebaseAuth/Sources/Swift/Utilities/AuthErrors.swift @@ -303,7 +303,7 @@ import Foundation /// Indicates that the nonce is missing or invalid. case missingOrInvalidNonce = 17094 - /// Raised when n Cloud Function returns a blocking error. Will include a message returned from + /// Raised when a Cloud Function returns a blocking error. Will include a message returned from /// the function. case blockingCloudFunctionError = 17105 diff --git a/FirebaseAuth/Tests/Unit/AuthBackendTests.swift b/FirebaseAuth/Tests/Unit/AuthBackendTests.swift index 748a5ba1982..73a74691e78 100644 --- a/FirebaseAuth/Tests/Unit/AuthBackendTests.swift +++ b/FirebaseAuth/Tests/Unit/AuthBackendTests.swift @@ -372,6 +372,51 @@ class AuthBackendTests: RPCBaseTests { } } + /// Test Blocking Function Error Response flow + func testBlockingFunctionError() async throws { + let kErrorMessageBlocking = "BLOCKING_FUNCTION_ERROR_RESPONSE" + let responseError = NSError(domain: kFakeErrorDomain, code: kFakeErrorCode) + let request = FakeRequest(withRequestBody: [:]) + rpcIssuer.respondBlock = { + try self.rpcIssuer.respond(serverErrorMessage: kErrorMessageBlocking, error: responseError) + } + do { + let _ = try await authBackend.call(with: request) + XCTFail("Expected to throw") + } catch { + let rpcError = error as NSError + XCTAssertEqual(rpcError.domain, AuthErrors.domain) + XCTAssertEqual(rpcError.code, AuthErrorCode.blockingCloudFunctionError.rawValue) + } + } + + /// Test Blocking Function Error Response flow - including JSON parsing. + /// Regression Test for #14052 + func testBlockingFunctionErrorWithJSON() async throws { + let kErrorMessageBlocking = "BLOCKING_FUNCTION_ERROR_RESPONSE" + let stringWithJSON = "BLOCKING_FUNCTION_ERROR_RESPONSE : ((HTTP request to" + + "http://127.0.0.1:9999/project-id/us-central1/beforeUserCreated returned HTTP error 400:" + + " {\"error\":{\"details\":{\"code\":\"invalid-email\"},\"message\":\"invalid " + + "email\",\"status\":\"INVALID_ARGUMENT\"}}))" + let responseError = NSError(domain: kFakeErrorDomain, code: kFakeErrorCode) + let request = FakeRequest(withRequestBody: [:]) + rpcIssuer.respondBlock = { + try self.rpcIssuer.respond( + serverErrorMessage: kErrorMessageBlocking + " : " + stringWithJSON, + error: responseError + ) + } + do { + let _ = try await authBackend.call(with: request) + XCTFail("Expected to throw") + } catch { + let rpcError = error as NSError + XCTAssertEqual(rpcError.domain, AuthErrors.domain) + XCTAssertEqual(rpcError.code, AuthErrorCode.blockingCloudFunctionError.rawValue) + XCTAssertEqual(rpcError.localizedDescription, "invalid email") + } + } + /** @fn testDecodableErrorResponseWithUnknownMessage @brief This test checks the behaviour of @c postWithRequest:response:callback: when the response deserialized by @c NSJSONSerialization represents a valid error response (and an