Files
iOS/Sources/App/Onboarding/API/OnboardingAuth.swift
Bruno Pantaleão Gonçalves 5320df2813 Update URL port and scheme at the end of webview login (#4728)
<!-- 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 -->
In case the mdns discovery starts the onboarding and suddenly the
onboarded url gets redirected to a different port or scheme, we are not
catching that and updating the onboarded URL to match that, more
information on the upper level task.
https://github.com/home-assistant/iOS/issues/4724

## 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. -->
2026-06-11 11:52:56 +02:00

260 lines
10 KiB
Swift

import Alamofire
import Foundation
import HAKit
import PromiseKit
import Shared
import SwiftUI
class OnboardingAuth {
func failureController(error: Error) -> UIViewController {
UIHostingController(rootView: OnboardingErrorView(error: error))
}
var login: OnboardingAuthLogin = OnboardingAuthLoginImpl()
var tokenExchange: OnboardingAuthTokenExchange = OnboardingAuthTokenExchangeImpl()
var preSteps: [OnboardingAuthPreStep.Type] = [
OnboardingAuthStepClientCertificate.self,
OnboardingAuthStepConnectivity.self,
]
var postSteps: [OnboardingAuthPostStep.Type] = [
OnboardingAuthStepDeviceNaming.self,
OnboardingAuthStepConfig.self,
OnboardingAuthStepSensors.self,
OnboardingAuthStepModels.self,
OnboardingAuthStepRegister.self,
OnboardingAuthStepNotify.self,
]
func authenticate(
to startInstance: DiscoveredHomeAssistant,
sender: UIViewController
) -> Promise<Server> {
firstly {
connect(to: startInstance, sender: sender)
}.then { [self] api -> Promise<Server> in
func steps(_ steps: OnboardingAuthStepPoint...) -> Promise<Void> {
var promise: Promise<Void> = .value(())
for step in steps {
promise = promise.then { [self] in
performPostSteps(checkPoint: step, api: api, sender: sender)
}
}
return promise
}
return firstly {
steps(.beforeRegister, .register, .afterRegister)
}.map {
// actually persists to outside-onboarding
Current.servers.add(identifier: api.server.identifier, serverInfo: api.server.info)
}.get { server in
// somewhat necessary so it points to the keychain-persisted version
api.server = server
// not super necessary but prevents making a duplicate connection during this session
Current.setCachedApi(api, for: api.server.identifier)
}.then { server in
steps(.complete).map { server }
}.recover(policy: .allErrors) { [self] error -> Promise<Server> in
when(resolved: undoConfigure(api: api)).then { _ in Promise<Server>(error: error) }
}
}
}
private func perform(checkPoint: OnboardingAuthStepPoint, checks: [OnboardingAuthStep]) -> Promise<Void> {
// Execute steps sequentially to allow ClientCertificate to complete before Connectivity
checks.reduce(Promise.value(())) { promise, check in
promise.then {
check.perform(point: checkPoint).tap { result in
Current.Log.info("\(type(of: check)): \(result)")
}.asVoid()
}
}
}
private func performPreSteps(
checkPoint: OnboardingAuthStepPoint,
authDetails: OnboardingAuthDetails,
sender: UIViewController
) -> Promise<Void> {
Current.Log.info(checkPoint)
return perform(checkPoint: checkPoint, checks: preSteps.compactMap { checkType in
if checkType.supportedPoints.contains(checkPoint) {
return checkType.init(authDetails: authDetails, sender: sender)
} else {
return nil
}
})
}
private func performPostSteps(
checkPoint: OnboardingAuthStepPoint,
api: HomeAssistantAPI,
sender: UIViewController
) -> Promise<Void> {
Current.Log.info(checkPoint)
return perform(checkPoint: checkPoint, checks: postSteps.compactMap { checkType in
if checkType.supportedPoints.contains(checkPoint) {
return checkType.init(api: api, sender: sender)
} else {
return nil
}
})
}
private func connect(
to baseInstance: DiscoveredHomeAssistant,
sender: UIViewController
) -> Promise<HomeAssistantAPI> {
// we prefer internal URL first, if it's available
var instances = [(URL, DiscoveredHomeAssistant)]()
if let internalURL = baseInstance.internalURL {
instances.append((internalURL, baseInstance))
}
if let externalURL = baseInstance.externalURL {
instances.append((externalURL, with(baseInstance) {
$0.internalURL = nil
}))
}
var promise: Promise<HomeAssistantAPI> = .init(error: OnboardingAuthError(kind: .invalidURL))
for (idx, (url, instance)) in instances.enumerated() {
promise = promise.recover { [self] originalError -> Promise<HomeAssistantAPI> in
let authDetails = try OnboardingAuthDetails(baseURL: url)
return firstly {
performPreSteps(checkPoint: .beforeAuth, authDetails: authDetails, sender: sender)
}.then { [self] in
login.open(authDetails: authDetails, sender: sender)
}.then { [self] result -> Promise<HomeAssistantAPI> in
// The login web view may have been redirected to a different port/scheme; adopt that
// address so the stored server URL (and the token exchange) target the real server.
let adoptedInstance = Self.instance(
instance,
adoptingResolvedURL: result.resolvedURL,
attemptedURL: url
)
return configuredAPI(authDetails: authDetails, instance: adoptedInstance, code: result.code)
}.recover { newError -> Promise<HomeAssistantAPI> in
if idx == 0 {
// we're the first/internal url, so our error should break the placeholder one
throw newError
} else {
// preserve the error we got for the internal url
throw originalError
}
}
}
}
return promise
}
/// When the login web view ends on a different URL than the one we started with, adopt that address
/// for the slot (internal/external) we attempted. Only same-host port/scheme redirects are adopted
/// a different host is ignored so we never follow an unexpected server.
static func instance(
_ instance: DiscoveredHomeAssistant,
adoptingResolvedURL resolvedURL: URL?,
attemptedURL: URL
) -> DiscoveredHomeAssistant {
let attemptedBase = attemptedURL.serverBaseURL()
guard let adoptedBase = resolvedURL?.sameHostRedirectBaseURL(from: attemptedURL) else {
if let resolvedBase = resolvedURL?.serverBaseURL(), !resolvedBase.baseIsEqual(to: attemptedBase) {
// Different host, or an https->http downgrade we won't follow.
Current.Log.warning("Not adopting auth redirect to \(resolvedBase); keeping \(attemptedBase)")
}
return instance
}
Current.Log.info("Adopting redirected server URL \(adoptedBase) (was \(attemptedBase))")
return with(instance) {
if let url = $0.internalURL, url.serverBaseURL().baseIsEqual(to: attemptedBase) {
$0.internalURL = adoptedBase
}
if let url = $0.externalURL, url.serverBaseURL().baseIsEqual(to: attemptedBase) {
$0.externalURL = adoptedBase
}
$0.internalOrExternalURL = $0.internalURL ?? $0.externalURL ?? adoptedBase
}
}
private func configuredAPI(
authDetails: OnboardingAuthDetails,
instance: DiscoveredHomeAssistant,
code: String
) -> Promise<HomeAssistantAPI> {
Current.Log.info()
var connectionInfo = ConnectionInfo(discovered: instance, authDetails: authDetails)
return tokenExchange.tokenInfo(
code: code,
connectionInfo: &connectionInfo
).then { tokenInfo -> Promise<HomeAssistantAPI> in
Current.Log.verbose()
var serverInfo = ServerInfo(
name: ServerInfo.defaultName,
connection: connectionInfo,
token: tokenInfo,
version: instance.version ?? DiscoveredHomeAssistant.defaultVersion
)
let identifier = Identifier<Server>(rawValue: instance.uuid ?? UUID().uuidString)
let server = Server(
identifier: identifier,
getter: { serverInfo },
setter: { serverInfo = $0; return true }
)
return .value(HomeAssistantAPI(server: server))
}
}
private func undoConfigure(api: HomeAssistantAPI) -> Promise<Void> {
Current.Log.info()
return firstly {
when(resolved: api.tokenManager.revokeToken()).asVoid()
}.done {
api.connection.disconnect()
Current.servers.remove(identifier: api.server.identifier)
}
}
}
private extension ConnectionInfo {
init(discovered: DiscoveredHomeAssistant, authDetails: OnboardingAuthDetails) {
self.init(
externalURL: discovered.externalURL,
internalURL: discovered.internalURL,
cloudhookURL: nil,
remoteUIURL: nil,
webhookID: "",
webhookSecret: nil,
internalSSIDs: Current.connectivity.currentWiFiSSID().map { [$0] },
internalHardwareAddresses: nil,
isLocalPushEnabled: false,
securityExceptions: authDetails.exceptions,
connectionAccessSecurityLevel: .undefined,
clientCertificate: authDetails.clientCertificate
)
// default cloud to on
useCloud = true
// if we have internal+external, we're on the internal network doing discovery
// but we don't yet have location permission to know we're on an internal ssid
if internalSSIDs == [] || internalSSIDs == nil,
discovered.internalURL != nil, discovered.externalURL != nil {
overrideActiveURLType = .internal
}
}
}