Files
iOS/Sources/App/Onboarding/API/Steps/OnboardingAuthStepConnectivity.swift
Bruno Pantaleão Gonçalves fb2e82d3e0 [mTLS] Use session-level auth handling and avoid main queue (#4591)
<!-- Thank you for submitting a Pull Request and helping to improve Home
Assistant. Please complete the following sections to help the processing
and review of your changes. Please do not delete anything from this
template. -->

## Summary
<!-- Provide a brief summary of the changes you have made and most
importantly what they aim to achieve -->

## Screenshots
<!-- If this is a user-facing change not in the frontend, please include
screenshots in light and dark mode. -->

## Link to pull request in Documentation repository
<!-- Pull requests that add, change or remove functionality must have a
corresponding pull request in the Companion App Documentation repository
(https://github.com/home-assistant/companion.home-assistant). Please add
the number of this pull request after the "#" -->
Documentation: home-assistant/companion.home-assistant#

## Any other notes
<!-- If there is any other information of note, like if this Pull
Request is part of a bigger change, please include it here. -->

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-30 14:10:51 +02:00

226 lines
9.1 KiB
Swift

import Foundation
import PromiseKit
import QuickLook
import Shared
import UIKit
class OnboardingAuthStepConnectivity: NSObject, OnboardingAuthPreStep, URLSessionTaskDelegate {
let authDetails: OnboardingAuthDetails
let sender: UIViewController
required init(authDetails: OnboardingAuthDetails, sender: UIViewController) {
self.authDetails = authDetails
self.sender = sender
super.init()
}
static var supportedPoints: Set<OnboardingAuthStepPoint> {
Set([.beforeAuth])
}
// Delegate callbacks and the recover block all read/write these from the main queue (the
// session is created with `delegateQueue: .main` and PromiseKit's default queue is also main),
// so they don't need locking. They are reset at the start of every `perform(point:)` because
// the same step instance can be reused for retries.
private var currentResolver: Resolver<Void>?
private var clientCertificateUnsupportedOccurred = false
private var clientCertificateRequiredOccurred = false
private var clientCertificateErrorOccurred = false
var prepareSessionConfiguration: ((URLSessionConfiguration) -> Void)?
func perform(point: OnboardingAuthStepPoint) -> Promise<Void> {
Current.Log.verbose()
let (promise, resolver) = Promise<Void>.pending()
performConnection(resolver: resolver)
return promise
}
private func performConnection(resolver: Resolver<Void>) {
currentResolver = resolver
clientCertificateUnsupportedOccurred = false
clientCertificateRequiredOccurred = false
clientCertificateErrorOccurred = false
let configuration = URLSessionConfiguration.ephemeral
prepareSessionConfiguration?(configuration)
let session = URLSession(configuration: configuration, delegate: self, delegateQueue: .main)
let (requestPromise, requestResolver) = Promise<(data: Data, response: URLResponse)>.pending()
let task = session.dataTask(with: authDetails.url) { data, response, error in
if let data, let response {
requestResolver.fulfill((data, response))
} else {
requestResolver.resolve(nil, error)
}
}
task.resume()
requestPromise
.validate()
.ensure {
withExtendedLifetime(session) {
// keep the session around
}
}
.map { _ in () }
.recover { [self] error throws in
let kind: OnboardingAuthError.ErrorKind
let data: Data?
switch error as? PMKHTTPError {
case let .badStatusCode(_, badStatusCodeData, _):
data = badStatusCodeData
case .none:
data = nil
}
if clientCertificateUnsupportedOccurred {
kind = .clientCertificateUnsupported
} else if clientCertificateRequiredOccurred {
kind = .clientCertificateRequired
} else if clientCertificateErrorOccurred {
kind = .clientCertificateError(error)
} else if let error = error as? URLError {
switch error.code {
case .serverCertificateUntrusted, .serverCertificateHasUnknownRoot, .serverCertificateHasBadDate,
.serverCertificateNotYetValid:
kind = .sslUntrusted([error])
default:
kind = .other(error)
}
} else {
kind = .other(error)
}
throw OnboardingAuthError(kind: kind, data: data)
}
.pipe(to: resolver.resolve)
}
private func confirm(
secTrust: SecTrust,
resolver: Resolver<Void>,
completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void
) {
do {
try authDetails.exceptions.evaluate(secTrust)
completionHandler(.useCredential, .init(trust: secTrust))
} catch {
Current.Log.error("received SSL error: \((error as NSError).debugDescription)")
var errors = [Error]()
errors.append(error)
if let underlying = (error as NSError).userInfo[NSUnderlyingErrorKey] as? Error {
errors.append(underlying)
}
// swiftformat:disable:next preferKeyPath
let alertMessage = errors.map { $0.localizedDescription }.joined(separator: "\n\n")
let alert = UIAlertController(
title: L10n.Onboarding.ConnectionTestResult.CertificateError.title,
message: alertMessage,
preferredStyle: .alert
)
alert.addAction(UIAlertAction(
title: L10n.Onboarding.ConnectionTestResult.CertificateError.actionTrust,
style: .destructive,
handler: { [self] _ in
authDetails.exceptions.add(for: secTrust)
confirm(secTrust: secTrust, resolver: resolver, completionHandler: completionHandler)
}
))
alert.addAction(UIAlertAction(
title: L10n.Onboarding.ConnectionTestResult.CertificateError.actionDontTrust,
style: .cancel,
handler: { _ in
resolver.reject(OnboardingAuthError(kind: .sslUntrusted(errors)))
completionHandler(.cancelAuthenticationChallenge, nil)
}
))
sender.present(alert, animated: true)
}
}
private func handleChallenge(
_ challenge: URLAuthenticationChallenge,
pendingResolver: Resolver<Void>,
completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void
) {
switch challenge.protectionSpace.authenticationMethod {
case NSURLAuthenticationMethodServerTrust:
guard let secTrust = challenge.protectionSpace.serverTrust else {
completionHandler(.performDefaultHandling, nil)
return
}
confirm(secTrust: secTrust, resolver: pendingResolver, completionHandler: completionHandler)
case NSURLAuthenticationMethodHTTPBasic:
pendingResolver.reject(OnboardingAuthError(kind: .basicAuth))
completionHandler(.cancelAuthenticationChallenge, nil)
case NSURLAuthenticationMethodClientCertificate:
// Use imported client certificate if available
#if !os(watchOS)
if let clientCert = authDetails.clientCertificate {
Current.Log.info("[mTLS] Using client certificate: \(clientCert.displayName)")
do {
let credential = try ClientCertificateManager.shared.urlCredential(for: clientCert)
completionHandler(.useCredential, credential)
return
} catch {
Current.Log.error("[mTLS] Failed to get credential: \(error)")
clientCertificateErrorOccurred = true
completionHandler(.performDefaultHandling, nil)
return
}
}
#endif
// No certificate available - server requires one
Current.Log.warning("[mTLS] Client certificate requested but none available")
clientCertificateRequiredOccurred = true
completionHandler(.performDefaultHandling, nil)
default:
pendingResolver
.reject(OnboardingAuthError(kind: .authenticationUnsupported(
challenge.protectionSpace.authenticationMethod
)))
completionHandler(.cancelAuthenticationChallenge, nil)
}
}
// CFNetwork dispatches server-trust to the session-level delegate method only. Without this,
// `authDetails.exceptions` is silently ignored and chain errors surface as a generic -1206.
// Once a session-level handler exists, the test URLProtocol routes everything through it too,
// so we forward all challenge types into the shared `handleChallenge`.
func urlSession(
_ session: URLSession,
didReceive challenge: URLAuthenticationChallenge,
completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void
) {
guard let pendingResolver = currentResolver else {
completionHandler(.cancelAuthenticationChallenge, nil)
return
}
handleChallenge(challenge, pendingResolver: pendingResolver, completionHandler: completionHandler)
}
func urlSession(
_ session: URLSession,
task: URLSessionTask,
didReceive challenge: URLAuthenticationChallenge,
completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void
) {
guard let pendingResolver = currentResolver else {
completionHandler(.cancelAuthenticationChallenge, nil)
return
}
handleChallenge(challenge, pendingResolver: pendingResolver, completionHandler: completionHandler)
}
}