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?
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.