Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[auth] Fix handling of cloud blocking function errors #14280

Merged
merged 1 commit into from
Dec 21, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading