From 5261dc383671f21e0a6e563169ed43ce97ba00ff Mon Sep 17 00:00:00 2001 From: krolakj Date: Mon, 13 Jul 2026 16:41:11 +0200 Subject: [PATCH 1/2] mTLS support --- .../Core/Network/NetworkSessionDelegate.swift | 19 +++++++++++++++++++ .../Core/Network/NetworkingClient.swift | 4 +++- .../Streaming/AudioPlayer/AudioPlayer.swift | 5 +++-- 3 files changed, 25 insertions(+), 3 deletions(-) diff --git a/AudioStreaming/Core/Network/NetworkSessionDelegate.swift b/AudioStreaming/Core/Network/NetworkSessionDelegate.swift index 588ae9d..55fc24f 100644 --- a/AudioStreaming/Core/Network/NetworkSessionDelegate.swift +++ b/AudioStreaming/Core/Network/NetworkSessionDelegate.swift @@ -6,8 +6,14 @@ import Foundation import OSLog +/// Provides a `URLCredential` in response to a client-certificate authentication challenge, +/// or `nil` to fall back to the system's default handling. Used to support mutual TLS (mTLS) +/// against servers that sit behind a client-certificate-authenticated proxy. +public typealias ClientCertificateProvider = (URLAuthenticationChallenge) -> URLCredential? + final class NetworkSessionDelegate: NSObject, URLSessionDataDelegate { weak var taskProvider: StreamTaskProvider? + var clientCertificateProvider: ClientCertificateProvider? func stream(for task: URLSessionTask) -> NetworkDataStream? { guard let taskProvider = taskProvider else { @@ -50,4 +56,17 @@ final class NetworkSessionDelegate: NSObject, URLSessionDataDelegate { stream.didReceive(response: response as? HTTPURLResponse) completionHandler(.allow) } + + func urlSession(_: URLSession, + didReceive challenge: URLAuthenticationChallenge, + completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) + { + guard challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodClientCertificate, + let credential = clientCertificateProvider?(challenge) + else { + completionHandler(.performDefaultHandling, nil) + return + } + completionHandler(.useCredential, credential) + } } diff --git a/AudioStreaming/Core/Network/NetworkingClient.swift b/AudioStreaming/Core/Network/NetworkingClient.swift index 49926e4..9fd1f5b 100644 --- a/AudioStreaming/Core/Network/NetworkingClient.swift +++ b/AudioStreaming/Core/Network/NetworkingClient.swift @@ -54,8 +54,10 @@ final class NetworkingClient { init(configuration: URLSessionConfiguration = .networkingConfiguration, delegate: NetworkSessionDelegate = NetworkSessionDelegate(), - networkQueue: DispatchQueue = DispatchQueue(label: "audio.streaming.session.network.queue")) + networkQueue: DispatchQueue = DispatchQueue(label: "audio.streaming.session.network.queue"), + clientCertificateProvider: ClientCertificateProvider? = nil) { + delegate.clientCertificateProvider = clientCertificateProvider let delegateQueue = operationQueue(underlyingQueue: networkQueue) let session = URLSession(configuration: configuration, delegate: delegate, delegateQueue: delegateQueue) self.session = session diff --git a/AudioStreaming/Streaming/AudioPlayer/AudioPlayer.swift b/AudioStreaming/Streaming/AudioPlayer/AudioPlayer.swift index 1987109..13fba34 100644 --- a/AudioStreaming/Streaming/AudioPlayer/AudioPlayer.swift +++ b/AudioStreaming/Streaming/AudioPlayer/AudioPlayer.swift @@ -183,7 +183,8 @@ open class AudioPlayer { /// Tracks the current loop iteration (how many times we've looped so far) private var currentLoopIteration: Int = 0 - public init(configuration: AudioPlayerConfiguration = .default) { + public init(configuration: AudioPlayerConfiguration = .default, + clientCertificateProvider: ClientCertificateProvider? = nil) { self.configuration = configuration.normalizeValues() let engine = AVAudioEngine() audioEngine = engine @@ -195,7 +196,7 @@ open class AudioPlayer { sourceQueue = DispatchQueue(label: "source.queue", qos: .default) entryProvider = AudioEntryProvider( - networkingClient: NetworkingClient(), + networkingClient: NetworkingClient(clientCertificateProvider: clientCertificateProvider), underlyingQueue: sourceQueue, outputAudioFormat: outputAudioFormat ) From 2514822db2f48e99607c891e72ef94c244f30572 Mon Sep 17 00:00:00 2001 From: krolakj Date: Mon, 27 Jul 2026 13:40:50 +0200 Subject: [PATCH 2/2] mTLS support - more test cases and stabilisation --- .../ClientCertificateChallengeTests.swift | 79 ++++++ .../Core/Network/MockHTTPServer.swift | 51 ++++ .../Network/MutualTLSIntegrationTests.swift | 75 +++++ .../Core/Network/NetworkingClientTests.swift | 23 +- .../Core/Network/TestTLSSupport.swift | 265 ++++++++++++++++++ 5 files changed, 489 insertions(+), 4 deletions(-) create mode 100644 AudioStreamingTests/Core/Network/ClientCertificateChallengeTests.swift create mode 100644 AudioStreamingTests/Core/Network/MockHTTPServer.swift create mode 100644 AudioStreamingTests/Core/Network/MutualTLSIntegrationTests.swift create mode 100644 AudioStreamingTests/Core/Network/TestTLSSupport.swift diff --git a/AudioStreamingTests/Core/Network/ClientCertificateChallengeTests.swift b/AudioStreamingTests/Core/Network/ClientCertificateChallengeTests.swift new file mode 100644 index 0000000..65baaf7 --- /dev/null +++ b/AudioStreamingTests/Core/Network/ClientCertificateChallengeTests.swift @@ -0,0 +1,79 @@ +import XCTest +@testable import AudioStreaming + +final class ClientCertificateChallengeTests: XCTestCase { + private final class NoopChallengeSender: NSObject, URLAuthenticationChallengeSender { + func use(_ credential: URLCredential, for challenge: URLAuthenticationChallenge) {} + func continueWithoutCredential(for challenge: URLAuthenticationChallenge) {} + func cancel(_ challenge: URLAuthenticationChallenge) {} + } + + private func makeChallenge(authenticationMethod: String) -> URLAuthenticationChallenge { + let space = URLProtectionSpace(host: "localhost", port: 443, protocol: "https", + realm: nil, authenticationMethod: authenticationMethod) + return URLAuthenticationChallenge(protectionSpace: space, proposedCredential: nil, + previousFailureCount: 0, failureResponse: nil, + error: nil, sender: NoopChallengeSender()) + } + + private func respond(_ delegate: NetworkSessionDelegate, + to challenge: URLAuthenticationChallenge) + -> (URLSession.AuthChallengeDisposition, URLCredential?) { + var disposition: URLSession.AuthChallengeDisposition! + var credential: URLCredential? + delegate.urlSession(URLSession.shared, didReceive: challenge) { d, c in + disposition = d + credential = c + } + return (disposition, credential) + } + + func test_clientCertificateChallenge_usesProvidedCredential() { + let expected = URLCredential(user: "u", password: "p", persistence: .forSession) + let delegate = NetworkSessionDelegate() + delegate.clientCertificateProvider = { _ in expected } + + let (disposition, credential) = respond(delegate, + to: makeChallenge(authenticationMethod: NSURLAuthenticationMethodClientCertificate)) + + XCTAssertEqual(disposition, .useCredential) + XCTAssertTrue(credential === expected) + } + + func test_clientCertificateChallenge_providerReturnsNil_fallsBackToDefault() { + let delegate = NetworkSessionDelegate() + delegate.clientCertificateProvider = { _ in nil } + + let (disposition, credential) = respond(delegate, + to: makeChallenge(authenticationMethod: NSURLAuthenticationMethodClientCertificate)) + + XCTAssertEqual(disposition, .performDefaultHandling) + XCTAssertNil(credential) + } + + func test_noProvider_fallsBackToDefault() { + let delegate = NetworkSessionDelegate() + + let (disposition, credential) = respond(delegate, + to: makeChallenge(authenticationMethod: NSURLAuthenticationMethodClientCertificate)) + + XCTAssertEqual(disposition, .performDefaultHandling) + XCTAssertNil(credential) + } + + func test_serverTrustChallenge_doesNotConsultProvider() { + var providerCalled = false + let delegate = NetworkSessionDelegate() + delegate.clientCertificateProvider = { _ in + providerCalled = true + return URLCredential(user: "u", password: "p", persistence: .forSession) + } + + let (disposition, credential) = respond(delegate, + to: makeChallenge(authenticationMethod: NSURLAuthenticationMethodServerTrust)) + + XCTAssertEqual(disposition, .performDefaultHandling) + XCTAssertNil(credential) + XCTAssertFalse(providerCalled) + } +} diff --git a/AudioStreamingTests/Core/Network/MockHTTPServer.swift b/AudioStreamingTests/Core/Network/MockHTTPServer.swift new file mode 100644 index 0000000..7780212 --- /dev/null +++ b/AudioStreamingTests/Core/Network/MockHTTPServer.swift @@ -0,0 +1,51 @@ +import Foundation +import Network + +/// A minimal local HTTP/1.1 server for tests: binds an ephemeral loopback port +/// and answers every request with 200 OK and a small JSON body. Plain HTTP (no +/// TLS) so requests through the production `NetworkingClient` connect without a +/// server-trust challenge. +final class MockHTTPServer { + private let listener: NWListener + private let queue = DispatchQueue(label: "mock.http.server") + private let body: String + private(set) var port: UInt16 = 0 + + init(body: String = #"{"status":"ok"}"#) throws { + self.body = body + let params = NWParameters.tcp + params.allowLocalEndpointReuse = true + listener = try NWListener(using: params, on: .any) + } + + func start() throws { + let ready = DispatchSemaphore(value: 0) + listener.stateUpdateHandler = { state in + if case .ready = state { ready.signal() } + } + listener.newConnectionHandler = { [weak self] connection in + self?.handle(connection) + } + listener.start(queue: queue) + _ = ready.wait(timeout: .now() + 5) + port = listener.port?.rawValue ?? 0 + } + + func stop() { + listener.cancel() + } + + private func handle(_ connection: NWConnection) { + connection.start(queue: queue) + connection.receive(minimumIncompleteLength: 1, maximumLength: 65536) { [body, weak connection] _, _, _, _ in + guard let connection else { return } + let response = "HTTP/1.1 200 OK\r\n" + + "Content-Type: application/json\r\n" + + "Content-Length: \(body.utf8.count)\r\n" + + "Connection: close\r\n\r\n" + + body + connection.send(content: response.data(using: .utf8), + completion: .contentProcessed { _ in connection.cancel() }) + } + } +} diff --git a/AudioStreamingTests/Core/Network/MutualTLSIntegrationTests.swift b/AudioStreamingTests/Core/Network/MutualTLSIntegrationTests.swift new file mode 100644 index 0000000..f8bf55b --- /dev/null +++ b/AudioStreamingTests/Core/Network/MutualTLSIntegrationTests.swift @@ -0,0 +1,75 @@ +import XCTest +import Security +@testable import AudioStreaming + +final class MutualTLSIntegrationTests: XCTestCase { + private var bundle: TestCertificateBundle! + private var server: MockMutualTLSServer! + + override func setUpWithError() throws { + try XCTSkipUnless(TestCertificateBundle.isOpenSSLAvailable, + "openssl not available at /usr/bin/openssl") + bundle = try TestCertificateBundle.generate() + server = try MockMutualTLSServer(serverIdentity: bundle.serverIdentity, + caCertificate: bundle.caCertificate) + try server.start() + XCTAssertGreaterThan(server.port, 0, "server failed to bind a port") + } + + override func tearDownWithError() throws { + server?.stop() + bundle?.cleanup() + } + + func test_certificateBundle_importsIdentities() throws { + XCTAssertNotNil(bundle.clientCredential.identity) + } + + func test_requestSucceeds_whenClientCertificateProvided() throws { + let inner = NetworkSessionDelegate() + let credential = bundle.clientCredential + inner.clientCertificateProvider = { _ in credential } + let delegate = TrustingSessionDelegate(caCertificate: bundle.caCertificate, inner: inner) + let session = URLSession(configuration: .ephemeral, delegate: delegate, delegateQueue: nil) + defer { session.invalidateAndCancel() } + + let url = URL(string: "https://localhost:\(server.port)/")! + let done = expectation(description: "request completes") + var statusCode: Int? + var body: String? + var requestError: Error? + session.dataTask(with: url) { data, response, error in + statusCode = (response as? HTTPURLResponse)?.statusCode + body = data.flatMap { String(data: $0, encoding: .utf8) } + requestError = error + done.fulfill() + }.resume() + wait(for: [done], timeout: 15) + + XCTAssertNil(requestError) + XCTAssertEqual(statusCode, 200) + XCTAssertEqual(body, "ok") + } + + func test_requestFails_whenClientCertificateMissing() throws { + let inner = NetworkSessionDelegate() + inner.clientCertificateProvider = { _ in nil } + let delegate = TrustingSessionDelegate(caCertificate: bundle.caCertificate, inner: inner) + let session = URLSession(configuration: .ephemeral, delegate: delegate, delegateQueue: nil) + defer { session.invalidateAndCancel() } + + let url = URL(string: "https://localhost:\(server.port)/")! + let done = expectation(description: "request completes") + var statusCode: Int? + var requestError: Error? + session.dataTask(with: url) { _, response, error in + statusCode = (response as? HTTPURLResponse)?.statusCode + requestError = error + done.fulfill() + }.resume() + wait(for: [done], timeout: 15) + + XCTAssertNotNil(requestError, "handshake should fail without a client certificate") + XCTAssertNotEqual(statusCode, 200) + } +} diff --git a/AudioStreamingTests/Core/Network/NetworkingClientTests.swift b/AudioStreamingTests/Core/Network/NetworkingClientTests.swift index eb3f420..9d96723 100644 --- a/AudioStreamingTests/Core/Network/NetworkingClientTests.swift +++ b/AudioStreamingTests/Core/Network/NetworkingClientTests.swift @@ -6,7 +6,22 @@ import XCTest @testable import AudioStreaming -class NetworkingClientTests: XCTestCase { +final class NetworkingClientTests: XCTestCase { + private var server: MockHTTPServer! + + override func setUpWithError() throws { + try super.setUpWithError() + server = try MockHTTPServer() + try server.start() + XCTAssertGreaterThan(server.port, 0, "server failed to bind a port") + } + + override func tearDownWithError() throws { + server?.stop() + server = nil + try super.tearDownWithError() + } + func testInitialiseCorrectly() throws { let networking = NetworkingClient() @@ -28,9 +43,9 @@ class NetworkingClientTests: XCTestCase { XCTAssertTrue(networking.networkQueue == queue) } - func testShouldStartRequestImmediatelly() { + func testShouldStartRequestImmediatelly() throws { let networking = NetworkingClient() - let url = URL(string: "https://httpbun.com/get")! + let url = URL(string: "http://127.0.0.1:\(server.port)/get")! let request = URLRequest(url: url) let expectation = self.expectation(description: "\(url)") @@ -58,8 +73,8 @@ class NetworkingClientTests: XCTestCase { waitForExpectations(timeout: 10, handler: nil) - XCTAssertEqual(responseCompletion?.response?.statusCode, 200) XCTAssertNotNil(responseCompletion) + XCTAssertEqual(responseCompletion?.response?.statusCode, 200) XCTAssertNotNil(receivedData) } } diff --git a/AudioStreamingTests/Core/Network/TestTLSSupport.swift b/AudioStreamingTests/Core/Network/TestTLSSupport.swift new file mode 100644 index 0000000..707a33c --- /dev/null +++ b/AudioStreamingTests/Core/Network/TestTLSSupport.swift @@ -0,0 +1,265 @@ +import Foundation +import Security +import Network +@testable import AudioStreaming + +/// Generates a throwaway CA plus server and client leaf certificates using the +/// system `openssl` (LibreSSL) binary, and imports them into Security types for +/// local mutual-TLS tests. Everything lives in a temp directory removed by +/// `cleanup()`. +struct TestCertificateBundle { + let directory: URL + let caCertificate: SecCertificate + let serverIdentity: SecIdentity + let clientCredential: URLCredential + + static let openSSLPath = "/usr/bin/openssl" + + static var isOpenSSLAvailable: Bool { + FileManager.default.isExecutableFile(atPath: openSSLPath) + } + + enum GenerationError: Error { + case openSSLFailed(args: [String], output: String) + case pkcs12ImportFailed(OSStatus) + case certificateLoadFailed + } + + static func generate(password: String = "test") throws -> TestCertificateBundle { + let dir = URL(fileURLWithPath: NSTemporaryDirectory()) + .appendingPathComponent("mtls-tests-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + + // Certificate authority + try runOpenSSL([ + "req", "-x509", "-newkey", "rsa:2048", "-nodes", + "-keyout", "ca.key", "-out", "ca.pem", "-days", "1", + "-subj", "/CN=AudioStreaming Test CA" + ], in: dir) + + // Server leaf with SAN=localhost, signed by the CA + try runOpenSSL([ + "req", "-newkey", "rsa:2048", "-nodes", + "-keyout", "server.key", "-out", "server.csr", + "-subj", "/CN=localhost" + ], in: dir) + try "subjectAltName=DNS:localhost\nextendedKeyUsage=serverAuth\n" + .write(to: dir.appendingPathComponent("server.ext"), atomically: true, encoding: .utf8) + try runOpenSSL([ + "x509", "-req", "-in", "server.csr", + "-CA", "ca.pem", "-CAkey", "ca.key", "-CAcreateserial", + "-out", "server.pem", "-days", "1", + "-extfile", "server.ext" + ], in: dir) + try runOpenSSL([ + "pkcs12", "-export", "-inkey", "server.key", "-in", "server.pem", + "-certfile", "ca.pem", "-out", "server.p12", + "-name", "server", "-passout", "pass:\(password)" + ], in: dir) + + // Client leaf, signed by the CA + try runOpenSSL([ + "req", "-newkey", "rsa:2048", "-nodes", + "-keyout", "client.key", "-out", "client.csr", + "-subj", "/CN=AudioStreaming Test Client" + ], in: dir) + try "extendedKeyUsage=clientAuth\n" + .write(to: dir.appendingPathComponent("client.ext"), atomically: true, encoding: .utf8) + try runOpenSSL([ + "x509", "-req", "-in", "client.csr", + "-CA", "ca.pem", "-CAkey", "ca.key", "-CAcreateserial", + "-out", "client.pem", "-days", "1", + "-extfile", "client.ext" + ], in: dir) + try runOpenSSL([ + "pkcs12", "-export", "-inkey", "client.key", "-in", "client.pem", + "-certfile", "ca.pem", "-out", "client.p12", + "-name", "client", "-passout", "pass:\(password)" + ], in: dir) + + let caCertificate = try loadCertificate(pemURL: dir.appendingPathComponent("ca.pem")) + let serverIdentity = try loadIdentity(p12URL: dir.appendingPathComponent("server.p12"), + password: password) + let clientIdentity = try loadIdentity(p12URL: dir.appendingPathComponent("client.p12"), + password: password) + let clientCredential = URLCredential(identity: clientIdentity, + certificates: nil, + persistence: .forSession) + + return TestCertificateBundle(directory: dir, + caCertificate: caCertificate, + serverIdentity: serverIdentity, + clientCredential: clientCredential) + } + + func cleanup() { + try? FileManager.default.removeItem(at: directory) + } + + @discardableResult + private static func runOpenSSL(_ args: [String], in dir: URL) throws -> String { + let process = Process() + process.executableURL = URL(fileURLWithPath: openSSLPath) + process.arguments = args + process.currentDirectoryURL = dir + let pipe = Pipe() + process.standardOutput = pipe + process.standardError = pipe + try process.run() + process.waitUntilExit() + let output = String(data: pipe.fileHandleForReading.readDataToEndOfFile(), + encoding: .utf8) ?? "" + guard process.terminationStatus == 0 else { + throw GenerationError.openSSLFailed(args: args, output: output) + } + return output + } + + private static func loadCertificate(pemURL: URL) throws -> SecCertificate { + let pem = try String(contentsOf: pemURL, encoding: .utf8) + let base64 = pem + .replacingOccurrences(of: "-----BEGIN CERTIFICATE-----", with: "") + .replacingOccurrences(of: "-----END CERTIFICATE-----", with: "") + .components(separatedBy: .whitespacesAndNewlines) + .joined() + guard let der = Data(base64Encoded: base64), + let cert = SecCertificateCreateWithData(nil, der as CFData) else { + throw GenerationError.certificateLoadFailed + } + return cert + } + + private static func loadIdentity(p12URL: URL, password: String) throws -> SecIdentity { + let data = try Data(contentsOf: p12URL) + let options = [kSecImportExportPassphrase as String: password] as CFDictionary + var items: CFArray? + let status = SecPKCS12Import(data as CFData, options, &items) + guard status == errSecSuccess, + let array = items as? [[String: Any]], + let identity = array.first?[kSecImportItemIdentity as String] else { + throw GenerationError.pkcs12ImportFailed(status) + } + return identity as! SecIdentity + } +} + +/// A local HTTPS server that requires and verifies a client certificate against +/// the test CA, then answers any request with a minimal HTTP 200. Binds an +/// ephemeral port so parallel tests don't collide. +final class MockMutualTLSServer { + private let listener: NWListener + private let queue = DispatchQueue(label: "mock.mtls.server") + private(set) var port: UInt16 = 0 + + init(serverIdentity: SecIdentity, caCertificate: SecCertificate) throws { + let tlsOptions = NWProtocolTLS.Options() + let sec = tlsOptions.securityProtocolOptions + guard let secIdentity = sec_identity_create(serverIdentity) else { + throw NSError(domain: "MockMutualTLSServer", code: 1, + userInfo: [NSLocalizedDescriptionKey: "sec_identity_create failed"]) + } + sec_protocol_options_set_local_identity(sec, secIdentity) + sec_protocol_options_set_peer_authentication_required(sec, true) + + let ca = caCertificate + sec_protocol_options_set_verify_block(sec, { _, secTrust, complete in + let trust = sec_trust_copy_ref(secTrust).takeRetainedValue() + // Replace the default SSL policy with a basic X.509 policy so the + // evaluation checks chain validity only, not EKU or hostname. + let basicPolicy = SecPolicyCreateBasicX509() + SecTrustSetPolicies(trust, basicPolicy) + SecTrustSetAnchorCertificates(trust, [ca] as CFArray) + SecTrustSetAnchorCertificatesOnly(trust, true) + complete(SecTrustEvaluateWithError(trust, nil)) + }, queue) + + let params = NWParameters(tls: tlsOptions) + params.allowLocalEndpointReuse = true + listener = try NWListener(using: params, on: .any) + } + + func start() throws { + let ready = DispatchSemaphore(value: 0) + listener.stateUpdateHandler = { state in + if case .ready = state { ready.signal() } + } + listener.newConnectionHandler = { [weak self] connection in + self?.handle(connection) + } + listener.start(queue: queue) + _ = ready.wait(timeout: .now() + 5) + port = listener.port?.rawValue ?? 0 + } + + func stop() { + listener.cancel() + } + + private func handle(_ connection: NWConnection) { + connection.start(queue: queue) + connection.receive(minimumIncompleteLength: 1, maximumLength: 65536) { _, _, _, _ in + let body = "ok" + let response = "HTTP/1.1 200 OK\r\n" + + "Content-Length: \(body.utf8.count)\r\n" + + "Connection: close\r\n\r\n" + + body + connection.send(content: response.data(using: .utf8), + completion: .contentProcessed { _ in connection.cancel() }) + } + } +} + +/// Test-only URLSession delegate that trusts the test CA for the server-trust +/// challenge and forwards the client-certificate challenge to the shipped +/// `NetworkSessionDelegate`. +/// +/// Why this exists: in a TLS handshake the client validates the server's +/// certificate BEFORE presenting its own. The shipped delegate returns +/// `.performDefaultHandling` for server-trust, which rejects a self-signed local +/// server and aborts before the client cert is ever sent. Trusting the test CA +/// here lets the handshake reach the client-cert stage, which the real delegate +/// then answers — so the shipped code path is what's under test. +final class TrustingSessionDelegate: NSObject, URLSessionDelegate, URLSessionTaskDelegate { + private let caCertificate: SecCertificate + private let inner: NetworkSessionDelegate + + init(caCertificate: SecCertificate, inner: NetworkSessionDelegate) { + self.caCertificate = caCertificate + self.inner = inner + } + + // Session-level challenge: handles server-trust, forwards client-cert to inner. + func urlSession(_ session: URLSession, + didReceive challenge: URLAuthenticationChallenge, + completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) { + handleChallenge(challenge, session: session, completionHandler: completionHandler) + } + + // Task-level challenge: same routing — mTLS client-cert challenges arrive here. + func urlSession(_ session: URLSession, + task: URLSessionTask, + didReceive challenge: URLAuthenticationChallenge, + completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) { + handleChallenge(challenge, session: session, completionHandler: completionHandler) + } + + private func handleChallenge(_ challenge: URLAuthenticationChallenge, + session: URLSession, + completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) { + if challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust { + guard let trust = challenge.protectionSpace.serverTrust else { + completionHandler(.cancelAuthenticationChallenge, nil) + return + } + SecTrustSetAnchorCertificates(trust, [caCertificate] as CFArray) + SecTrustSetAnchorCertificatesOnly(trust, true) + if SecTrustEvaluateWithError(trust, nil) { + completionHandler(.useCredential, URLCredential(trust: trust)) + } else { + completionHandler(.cancelAuthenticationChallenge, nil) + } + } else { + inner.urlSession(session, didReceive: challenge, completionHandler: completionHandler) + } + } +}