URLError from rejecting a URLAuthenticationChallenge is too generic

50 Views Asked by At

According to this article when communicating with hardware accessories in your local network over HTTPS securely, you should be pinning your certificates like this:

In the URLSessionDelegate implement this method:

func urlSession(_ session: URLSession, didReceive challenge: URLAuthenticationChallenge) async -> (URLSession.AuthChallengeDisposition, URLCredential?)

Then do your verification via SecTrustEvaluateWithError and return (.useCredential, URLCredential(trust: trust)) if it was successful and (.cancelAuthenticationChallenge, nil) if it was not.

However, the error returned when rejecting the challenge in try await URLSession(...).data(from:) is a URLError with the code -999 which is URLError.Code.cancelled.

How would I distinguish between other cancellations, so I could tell the user that the request failed because of an invalid certificate? The documentation of URLError.Code.cancelled is very generic, so I cannot solely rely on this error (or can I?).

This article suggests that we should be showing the error right in the URLSessionDelegate which does not seem like a good solution to me since user interface code would leak into networking code.

Are there any other techniques, I could apply here to specifically catch the error from URLSession when the certificate was declined by my own validation logic?

1

There are 1 best solutions below

0
CouchDeveloper On BEST ANSWER

You need to solve this issue with a custom "HTTPClient" which internally uses a URLSession, and a URLSession API where you can specify a task specific delegate.

The idea is, to use a dedicated task specific delegate, where you store the error of that request. Then, later when the URLSession data tasks returns with a cancellation error, check the delegate's error property if there is more information.

The below code give you an idea to start with:

First, you need a class conforming to URLSessionTaskDelegate, which can be instantiated as a separate object. In your case it specifies delegate method which handles the authentication challenge.

Define the delegate:

final class AuthenticationChallengeHandler: NSObject, URLSessionTaskDelegate  {
    
    struct Error: LocalizedError {
        var errorDescription: String? {
            "server authentication failed"
        }
    }
    
    init(expectedCertificate: SecCertificate? = nil) {
        self.expectedCertificate = expectedCertificate
    }
   
    private var expectedCertificate: SecCertificate?
    
    
    var error: Error?

    func shouldAllowHTTPSConnection(trust: SecTrust) async -> Bool {
        self.error = Error()
        return false
    }

    
    func urlSession(
        _ session: URLSession,
        didReceive challenge: URLAuthenticationChallenge
    ) async -> (URLSession.AuthChallengeDisposition, URLCredential?) {
        switch challenge.protectionSpace.authenticationMethod {
        case NSURLAuthenticationMethodServerTrust:
            let trust = challenge.protectionSpace.serverTrust!
            guard await self.shouldAllowHTTPSConnection(trust: trust) else {
                return (.cancelAuthenticationChallenge, nil)
            }
            let credential = URLCredential(trust: trust)
            return (.useCredential, credential)
        default:
            return (.performDefaultHandling, nil)
        }
    }
    
}

Note, that the delegate has a property error.

Second, you want to use a dedicated instance of this delegate for executing a single request. Here, you can use the following URLSession APIs which have a parameter for a dedicated URLSessionTaskDelegate. For example:

    /// Convenience method to load data using a URL, creates and 
    /// resumes a URLSessionDataTask internally.
    ///
    /// - Parameter url: The URL for which to load data.
    /// - Parameter delegate: Task-specific delegate.
    /// - Returns: Data and response.
    public func data(
        from url: URL, 
        delegate: (URLSessionTaskDelegate)? = nil
    ) async throws -> (Data, URLResponse)

You can then use it like below:


    func testExample() async throws {
        let url = URL(string: "https://www.example.com")!
        let delegate = AuthenticationChallengeHandler()
        do {
            let (data, response) = try await URLSession.shared.data(
               from: url, 
               delegate: delegate
            )
        } catch {
            print(error.localizedDescription)
            print(delegate.error?.localizedDescription)
        }
        
    }

Of course, you should improve this above code in your custom "HTTPClient" implementation.