Skip to content

Commit cf3e902

Browse files
authored
[auth] Fix handling of cloud blocking function errors (#14280)
1 parent afd83c3 commit cf3e902

File tree

5 files changed

+110
-19
lines changed

5 files changed

+110
-19
lines changed

FirebaseAuth/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
- [fixed] Updated most decoders to be consistent with Firebase 10's behavior
44
for decoding `nil` values. (#14212)
55
- [fixed] Address Xcode 16.2 concurrency compile time issues. (#14279)
6+
- [fixed] Fix handling of cloud blocking function errors. (#14052)
67

78
# 11.6.0
89
- [added] Added reCAPTCHA Enterprise support for app verification during phone

FirebaseAuth/Sources/Swift/Backend/AuthBackend.swift

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -293,14 +293,22 @@ final class AuthBackend: AuthBackendProtocol {
293293
.unexpectedErrorResponse(deserializedResponse: dictionary, underlyingError: error)
294294
}
295295

296+
private static func splitStringAtFirstColon(_ input: String) -> (before: String, after: String) {
297+
guard let colonIndex = input.firstIndex(of: ":") else {
298+
return (input, "") // No colon, return original string before and empty after
299+
}
300+
let before = String(input.prefix(upTo: colonIndex))
301+
.trimmingCharacters(in: .whitespacesAndNewlines)
302+
let after = String(input.suffix(from: input.index(after: colonIndex)))
303+
.trimmingCharacters(in: .whitespacesAndNewlines)
304+
return (before, after.isEmpty ? "" : after) // Return empty after if it's empty
305+
}
306+
296307
private static func clientError(withServerErrorMessage serverErrorMessage: String,
297308
errorDictionary: [String: Any],
298309
response: AuthRPCResponse?,
299310
error: Error?) -> Error? {
300-
let split = serverErrorMessage.split(separator: ":")
301-
let shortErrorMessage = split.first?.trimmingCharacters(in: .whitespacesAndNewlines)
302-
let serverDetailErrorMessage = String(split.count > 1 ? split[1] : "")
303-
.trimmingCharacters(in: .whitespacesAndNewlines)
311+
let (shortErrorMessage, serverDetailErrorMessage) = splitStringAtFirstColon(serverErrorMessage)
304312
switch shortErrorMessage {
305313
case "USER_NOT_FOUND": return AuthErrorUtils
306314
.userNotFoundError(message: serverDetailErrorMessage)

FirebaseAuth/Sources/Swift/Utilities/AuthErrorUtils.swift

Lines changed: 51 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -510,25 +510,62 @@ class AuthErrorUtils {
510510
return error(code: .accountExistsWithDifferentCredential, userInfo: userInfo)
511511
}
512512

513+
private static func extractJSONObjectFromString(from string: String) -> [String: Any]? {
514+
// 1. Find the start of the JSON object.
515+
guard let start = string.firstIndex(of: "{") else {
516+
return nil // No JSON object found
517+
}
518+
// 2. Find the end of the JSON object.
519+
// Start from the first curly brace `{`
520+
var curlyLevel = 0
521+
var endIndex: String.Index?
522+
523+
for index in string.indices.suffix(from: start) {
524+
let char = string[index]
525+
if char == "{" {
526+
curlyLevel += 1
527+
} else if char == "}" {
528+
curlyLevel -= 1
529+
if curlyLevel == 0 {
530+
endIndex = index
531+
break
532+
}
533+
}
534+
}
535+
guard let end = endIndex else {
536+
return nil // Unbalanced curly braces
537+
}
538+
539+
// 3. Extract the JSON string.
540+
let jsonString = String(string[start ... end])
541+
542+
// 4. Convert JSON String to JSON Object
543+
guard let jsonData = jsonString.data(using: .utf8) else {
544+
return nil // Could not convert String to Data
545+
}
546+
547+
do {
548+
if let jsonObject = try JSONSerialization
549+
.jsonObject(with: jsonData, options: []) as? [String: Any] {
550+
return jsonObject
551+
} else {
552+
return nil // JSON Object is not a dictionary
553+
}
554+
} catch {
555+
return nil // Failed to deserialize JSON
556+
}
557+
}
558+
513559
static func blockingCloudFunctionServerResponse(message: String?) -> Error {
514560
guard let message else {
515561
return error(code: .blockingCloudFunctionError, message: message)
516562
}
517-
var jsonString = message.replacingOccurrences(
518-
of: "HTTP Cloud Function returned an error:",
519-
with: ""
520-
)
521-
jsonString = jsonString.trimmingCharacters(in: .whitespaces)
522-
let jsonData = jsonString.data(using: .utf8) ?? Data()
523-
do {
524-
let jsonDict = try JSONSerialization
525-
.jsonObject(with: jsonData, options: []) as? [String: Any] ?? [:]
526-
let errorDict = jsonDict["error"] as? [String: Any] ?? [:]
527-
let errorMessage = errorDict["message"] as? String
528-
return error(code: .blockingCloudFunctionError, message: errorMessage)
529-
} catch {
530-
return JSONSerializationError(underlyingError: error)
563+
guard let jsonDict = extractJSONObjectFromString(from: message) else {
564+
return error(code: .blockingCloudFunctionError, message: message)
531565
}
566+
let errorDict = jsonDict["error"] as? [String: Any] ?? [:]
567+
let errorMessage = errorDict["message"] as? String
568+
return error(code: .blockingCloudFunctionError, message: errorMessage)
532569
}
533570

534571
#if os(iOS)

FirebaseAuth/Sources/Swift/Utilities/AuthErrors.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -303,7 +303,7 @@ import Foundation
303303
/// Indicates that the nonce is missing or invalid.
304304
case missingOrInvalidNonce = 17094
305305

306-
/// Raised when n Cloud Function returns a blocking error. Will include a message returned from
306+
/// Raised when a Cloud Function returns a blocking error. Will include a message returned from
307307
/// the function.
308308
case blockingCloudFunctionError = 17105
309309

FirebaseAuth/Tests/Unit/AuthBackendTests.swift

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -372,6 +372,51 @@ class AuthBackendTests: RPCBaseTests {
372372
}
373373
}
374374

375+
/// Test Blocking Function Error Response flow
376+
func testBlockingFunctionError() async throws {
377+
let kErrorMessageBlocking = "BLOCKING_FUNCTION_ERROR_RESPONSE"
378+
let responseError = NSError(domain: kFakeErrorDomain, code: kFakeErrorCode)
379+
let request = FakeRequest(withRequestBody: [:])
380+
rpcIssuer.respondBlock = {
381+
try self.rpcIssuer.respond(serverErrorMessage: kErrorMessageBlocking, error: responseError)
382+
}
383+
do {
384+
let _ = try await authBackend.call(with: request)
385+
XCTFail("Expected to throw")
386+
} catch {
387+
let rpcError = error as NSError
388+
XCTAssertEqual(rpcError.domain, AuthErrors.domain)
389+
XCTAssertEqual(rpcError.code, AuthErrorCode.blockingCloudFunctionError.rawValue)
390+
}
391+
}
392+
393+
/// Test Blocking Function Error Response flow - including JSON parsing.
394+
/// Regression Test for #14052
395+
func testBlockingFunctionErrorWithJSON() async throws {
396+
let kErrorMessageBlocking = "BLOCKING_FUNCTION_ERROR_RESPONSE"
397+
let stringWithJSON = "BLOCKING_FUNCTION_ERROR_RESPONSE : ((HTTP request to" +
398+
"http://127.0.0.1:9999/project-id/us-central1/beforeUserCreated returned HTTP error 400:" +
399+
" {\"error\":{\"details\":{\"code\":\"invalid-email\"},\"message\":\"invalid " +
400+
"email\",\"status\":\"INVALID_ARGUMENT\"}}))"
401+
let responseError = NSError(domain: kFakeErrorDomain, code: kFakeErrorCode)
402+
let request = FakeRequest(withRequestBody: [:])
403+
rpcIssuer.respondBlock = {
404+
try self.rpcIssuer.respond(
405+
serverErrorMessage: kErrorMessageBlocking + " : " + stringWithJSON,
406+
error: responseError
407+
)
408+
}
409+
do {
410+
let _ = try await authBackend.call(with: request)
411+
XCTFail("Expected to throw")
412+
} catch {
413+
let rpcError = error as NSError
414+
XCTAssertEqual(rpcError.domain, AuthErrors.domain)
415+
XCTAssertEqual(rpcError.code, AuthErrorCode.blockingCloudFunctionError.rawValue)
416+
XCTAssertEqual(rpcError.localizedDescription, "invalid email")
417+
}
418+
}
419+
375420
/** @fn testDecodableErrorResponseWithUnknownMessage
376421
@brief This test checks the behaviour of @c postWithRequest:response:callback: when the
377422
response deserialized by @c NSJSONSerialization represents a valid error response (and an

0 commit comments

Comments
 (0)