mirror of
https://github.com/home-assistant/iOS.git
synced 2026-06-16 13:26:27 -05:00
<!-- 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>
226 lines
9.1 KiB
Swift
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)
|
|
}
|
|
}
|