Skip to content

Commit

Permalink
[auth] Fix handling of cloud blocking function errors
Browse files Browse the repository at this point in the history
  • Loading branch information
paulb777 committed Dec 21, 2024
1 parent afd83c3 commit 7536f24
Show file tree
Hide file tree
Showing 5 changed files with 110 additions and 19 deletions.
1 change: 1 addition & 0 deletions FirebaseAuth/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
16 changes: 12 additions & 4 deletions FirebaseAuth/Sources/Swift/Backend/AuthBackend.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
65 changes: 51 additions & 14 deletions FirebaseAuth/Sources/Swift/Utilities/AuthErrorUtils.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion FirebaseAuth/Sources/Swift/Utilities/AuthErrors.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
45 changes: 45 additions & 0 deletions FirebaseAuth/Tests/Unit/AuthBackendTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down

0 comments on commit 7536f24

Please sign in to comment.