diff --git a/HomeAssistant.xcodeproj/project.pbxproj b/HomeAssistant.xcodeproj/project.pbxproj index d6e40adc25..833e4aafd2 100644 --- a/HomeAssistant.xcodeproj/project.pbxproj +++ b/HomeAssistant.xcodeproj/project.pbxproj @@ -1482,6 +1482,7 @@ ConfigurationTransfer/AppSettingsSnapshot.swift, ConfigurationTransfer/ImportExportConfigurationView.swift, ConfigurationTransfer/ImportExportConfigurationViewModel.swift, + Connection/ClientCertificateImportFormSheet.swift, Connection/ClientCertificateView.swift, Connection/ConnectionHardwareAddressesView.swift, Connection/ConnectionSettingsView.swift, @@ -1494,6 +1495,8 @@ Connection/ServerDetailsRowView.swift, Connection/ServerEditMenu.swift, Connection/ServerHowItWorksSheet.swift, + Connection/ServerReauthenticationModel.swift, + Connection/ServerReauthenticationModifier.swift, Connection/ServerSettingsSheet.swift, Connection/ServerURLEditorSheet.swift, Connection/Webhook/WebhookDetailView.swift, @@ -1720,6 +1723,7 @@ ConfigurationTransfer/AppSettingsSnapshot.swift, ConfigurationTransfer/ImportExportConfigurationView.swift, ConfigurationTransfer/ImportExportConfigurationViewModel.swift, + Connection/ClientCertificateImportFormSheet.swift, Connection/ClientCertificateView.swift, Connection/ConnectionHardwareAddressesView.swift, Connection/ConnectionSettingsView.swift, @@ -1732,6 +1736,8 @@ Connection/ServerDetailsRowView.swift, Connection/ServerEditMenu.swift, Connection/ServerHowItWorksSheet.swift, + Connection/ServerReauthenticationModel.swift, + Connection/ServerReauthenticationModifier.swift, Connection/ServerSettingsSheet.swift, Connection/ServerURLEditorSheet.swift, Connection/Webhook/WebhookDetailView.swift, diff --git a/Sources/App/Container/OnboardingStateObservable.swift b/Sources/App/Container/OnboardingStateObservable.swift index 210b8b1141..fa4bf4f962 100644 --- a/Sources/App/Container/OnboardingStateObservable.swift +++ b/Sources/App/Container/OnboardingStateObservable.swift @@ -118,18 +118,7 @@ final class OnboardingStateObservable: ObservableObject { server.info.connection.availableAuthenticationURLTypes } - var clientCertificateRequirement: (URL) -> Promise = - OnboardingAuthStepClientCertificate.requiresClientCertificate(at:) - - var fetchToken: (String, URL, SecurityExceptions, ClientCertificate?) -> Promise = { - code, baseURL, exceptions, clientCertificate in - AuthenticationAPI.fetchToken( - authorizationCode: code, - baseURL: baseURL, - exceptions: exceptions, - clientCertificate: clientCertificate - ) - } + var reauthenticator = ServerReauthenticator() /// Re-authenticates a server recovered from a keychain-mirror restore, then shows its web view. /// Moved from `WebViewWindowController`; `login` is provided by the re-auth screen, which presents @@ -141,68 +130,24 @@ final class OnboardingStateObservable: ObservableObject { importClientCertificate: @escaping () -> Promise, completion: @escaping (Swift.Result) -> Void ) { - let connectionInfo = server.info.connection - guard let baseURL = connectionInfo.address(for: urlType) else { - completion(.failure(ServerConnectionError.noActiveURL(server.info.name))) - return - } - do { - let authDetails = try OnboardingAuthDetails(baseURL: baseURL) - authDetails.exceptions = connectionInfo.securityExceptions - authDetails.clientCertificate = connectionInfo.clientCertificate - firstly { [self] in - ensureClientCertificate(for: authDetails, importClientCertificate: importClientCertificate) - }.then { - login(authDetails) - }.then { [self] result -> Promise<(URL?, TokenInfo)> in - let correctedURL = result.resolvedURL?.sameHostRedirectBaseURL(from: baseURL) - return fetchToken( - result.code, - correctedURL ?? baseURL, - authDetails.exceptions, - authDetails.clientCertificate - ).map { (correctedURL, $0) } - }.done { [weak self] correctedURL, tokenInfo in - server.update { serverInfo in - serverInfo.token = tokenInfo - if let certificate = authDetails.clientCertificate { - serverInfo.connection.clientCertificate = certificate - } - if let correctedURL { - Current.Log.info("Updating \(urlType) URL to redirect \(correctedURL) during re-auth") - serverInfo.connection.set(address: correctedURL, for: urlType) - } - } - completion(.success(())) - self?.showWebView(for: server) - }.catch(policy: .allErrors) { error in - if let pmkError = error as? PMKError, pmkError.isCancelled { - completion(.failure(RecoveredServerReauthenticationError.cancelled)) - return - } - Current.Log.error("Recovered server re-authentication failed: \(error)") - completion(.failure(error)) + reauthenticator.reauthenticate( + server, + using: urlType, + login: login, + importClientCertificate: importClientCertificate + ).done { [weak self] in + completion(.success(())) + self?.showWebView(for: server) + }.catch(policy: .allErrors) { error in + if let pmkError = error as? PMKError, pmkError.isCancelled { + completion(.failure(RecoveredServerReauthenticationError.cancelled)) + return } - } catch { - Current.Log.error("Failed to create auth details for recovered server re-authentication: \(error)") + Current.Log.error("Recovered server re-authentication failed: \(error)") completion(.failure(error)) } } - private func ensureClientCertificate( - for authDetails: OnboardingAuthDetails, - importClientCertificate: @escaping () -> Promise - ) -> Promise { - guard authDetails.clientCertificate == nil else { return .value(()) } - return clientCertificateRequirement(authDetails.url).then { required -> Promise in - guard required else { return .value(()) } - Current.Log.info("[mTLS] Recovered server requires a client certificate, asking for one") - return importClientCertificate().done { certificate in - authDetails.clientCertificate = certificate - } - } - } - /// Mirrors `WebViewWindowController.setup()`: onboarding when required, otherwise the last viewed /// server's web view (restoring its last path). private static func initialScreen() -> Screen { diff --git a/Sources/App/Container/ServerReauthenticator.swift b/Sources/App/Container/ServerReauthenticator.swift new file mode 100644 index 0000000000..5a3925ada0 --- /dev/null +++ b/Sources/App/Container/ServerReauthenticator.swift @@ -0,0 +1,84 @@ +import Foundation +import PromiseKit +import Shared + +/// Logs an already-registered server in again and stores the new token, leaving everything else about +/// the server (its URLs, webhook and device registration) as it is. Shared by the recovered-server +/// re-auth screen and the Re-authenticate button in a server's details; the caller presents the login +/// web view through `login` and decides what happens once the token is stored. +struct ServerReauthenticator { + /// Posted with the `Server` as object once a re-authentication stored its new token, so a web view + /// showing that server's logged-out state can load the frontend again. + static let didReauthenticateNotification = Notification.Name("ServerReauthenticatorDidReauthenticate") + + var clientCertificateRequirement: (URL) -> Promise = + OnboardingAuthStepClientCertificate.requiresClientCertificate(at:) + + var fetchToken: (String, URL, SecurityExceptions, ClientCertificate?) -> Promise = { + code, baseURL, exceptions, clientCertificate in + AuthenticationAPI.fetchToken( + authorizationCode: code, + baseURL: baseURL, + exceptions: exceptions, + clientCertificate: clientCertificate + ) + } + + /// Runs the login flow against the server's `urlType` address. A login page redirected to another + /// port or scheme on the same host also moves the stored address there. Rejects with + /// `PMKError.cancelled` when the user cancels the login. + func reauthenticate( + _ server: Server, + using urlType: ConnectionInfo.URLType, + login: @escaping (OnboardingAuthDetails) -> Promise, + importClientCertificate: @escaping () -> Promise + ) -> Promise { + let connectionInfo = server.info.connection + guard let baseURL = connectionInfo.address(for: urlType) else { + return Promise(error: ServerConnectionError.noActiveURL(server.info.name)) + } + + return firstly { () -> Promise in + let authDetails = try OnboardingAuthDetails(baseURL: baseURL) + authDetails.exceptions = connectionInfo.securityExceptions + authDetails.clientCertificate = connectionInfo.clientCertificate + return ensureClientCertificate(for: authDetails, importClientCertificate: importClientCertificate) + .map { authDetails } + }.then { authDetails in + login(authDetails).map { (authDetails, $0) } + }.then { authDetails, result -> Promise<(OnboardingAuthDetails, URL?, TokenInfo)> in + let correctedURL = result.resolvedURL?.sameHostRedirectBaseURL(from: baseURL) + return fetchToken( + result.code, + correctedURL ?? baseURL, + authDetails.exceptions, + authDetails.clientCertificate + ).map { (authDetails, correctedURL, $0) } + }.done { authDetails, correctedURL, tokenInfo in + server.update { serverInfo in + serverInfo.token = tokenInfo + if let certificate = authDetails.clientCertificate { + serverInfo.connection.clientCertificate = certificate + } + if let correctedURL { + Current.Log.info("Updating \(urlType) URL to redirect \(correctedURL) during re-auth") + serverInfo.connection.set(address: correctedURL, for: urlType) + } + } + } + } + + private func ensureClientCertificate( + for authDetails: OnboardingAuthDetails, + importClientCertificate: @escaping () -> Promise + ) -> Promise { + guard authDetails.clientCertificate == nil else { return .value(()) } + return clientCertificateRequirement(authDetails.url).then { required -> Promise in + guard required else { return .value(()) } + Current.Log.info("[mTLS] Server requires a client certificate for re-authentication, asking for one") + return importClientCertificate().done { certificate in + authDetails.clientCertificate = certificate + } + } + } +} diff --git a/Sources/App/Frontend/WebView/WebViewController/WebViewController+ReAuth.swift b/Sources/App/Frontend/WebView/WebViewController/WebViewController+ReAuth.swift index 522cc9c457..a615794a47 100644 --- a/Sources/App/Frontend/WebView/WebViewController/WebViewController+ReAuth.swift +++ b/Sources/App/Frontend/WebView/WebViewController/WebViewController+ReAuth.swift @@ -117,6 +117,20 @@ extension WebViewController { Current.Log.info("Re-authentication successful, tokens updated and WebView refreshing") } + /// The server was re-authenticated somewhere else (its settings), so leave a logged-out or + /// auth-invalid state behind and reload the frontend, which may now belong to a different user. + @objc func serverDidReauthenticate(_ notification: Notification) { + guard let reauthenticatedServer = notification.object as? Server, + reauthenticatedServer.identifier == server.identifier else { return } + DispatchQueue.main.async { [self] in + Current.Log.info("Server \(server.identifier.rawValue) re-authenticated, reloading its web view") + didLogOut = false + connectionState = .unknown + hideEmptyState() + refresh() + } + } + private func showReauthFailureAlert(error: Error) { let alert = UIAlertController( title: L10n.Alerts.AuthRequired.title, diff --git a/Sources/App/Frontend/WebView/WebViewController/WebViewController+URLLoading.swift b/Sources/App/Frontend/WebView/WebViewController/WebViewController+URLLoading.swift index f9bf505830..a888a3cc00 100644 --- a/Sources/App/Frontend/WebView/WebViewController/WebViewController+URLLoading.swift +++ b/Sources/App/Frontend/WebView/WebViewController/WebViewController+URLLoading.swift @@ -48,6 +48,13 @@ extension WebViewController { object: nil ) + NotificationCenter.default.addObserver( + self, + selector: #selector(serverDidReauthenticate(_:)), + name: ServerReauthenticator.didReauthenticateNotification, + object: nil + ) + tokens.append(server.observe { [weak self] _ in self?.connectionInfoDidChange() }) diff --git a/Sources/App/Resources/en.lproj/Localizable.strings b/Sources/App/Resources/en.lproj/Localizable.strings index 03b5a7365b..2759228f2d 100644 --- a/Sources/App/Resources/en.lproj/Localizable.strings +++ b/Sources/App/Resources/en.lproj/Localizable.strings @@ -1684,6 +1684,11 @@ Home Assistant is open source, advocates for privacy and runs locally in your ho "settings.connection_section.open_server" = "Open"; "settings.connection_section.refresh_server" = "Update server information"; "settings.connection_section.remote_ui_url.title" = "Remote UI URL"; +"settings.connection_section.reauthenticate.error_title" = "Couldn't re-authenticate"; +"settings.connection_section.reauthenticate.footer" = "Sign in to this server again, for example after changing your password or to use a different user. Your server's URLs and settings stay as they are."; +"settings.connection_section.reauthenticate.progress" = "Re-authenticating…"; +"settings.connection_section.reauthenticate.title" = "Re-authenticate"; +"settings.connection_section.reauthenticate.url_picker_title" = "Sign in using"; "settings.connection_section.sensor_send_type.setting.all" = "All"; "settings.connection_section.sensor_send_type.setting.none" = "None"; "settings.connection_section.sensor_send_type.title" = "Sensors Sent"; diff --git a/Sources/App/Settings/Connection/ClientCertificateImportFormSheet.swift b/Sources/App/Settings/Connection/ClientCertificateImportFormSheet.swift new file mode 100644 index 0000000000..760c18b52c --- /dev/null +++ b/Sources/App/Settings/Connection/ClientCertificateImportFormSheet.swift @@ -0,0 +1,43 @@ +import Shared +import SwiftUI +import UIKit + +/// Presents the client certificate import as a UIKit form sheet while `request` is set, and dismisses +/// it once the request is answered. Used on Mac Catalyst, where SwiftUI sheet content doesn't receive +/// mouse events reliably; the web view presents the same import this way. +struct ClientCertificateImportFormSheet: UIViewControllerRepresentable { + let request: OnboardingClientCertificateRequest? + + final class Coordinator { + /// Only this sheet is dismissed: the presenting controller may be showing something else. + weak var presented: UIViewController? + } + + func makeCoordinator() -> Coordinator { + Coordinator() + } + + func makeUIViewController(context: Context) -> UIViewController { + UIViewController() + } + + func updateUIViewController(_ controller: UIViewController, context: Context) { + let coordinator = context.coordinator + if let request, coordinator.presented == nil { + let sheet = UIHostingController(rootView: NavigationView { + ClientCertificateOnboardingView(onImport: request.complete(with:), onCancel: request.cancel) + }.navigationViewStyle(.stack)) + sheet.modalPresentationStyle = .formSheet + sheet.isModalInPresentation = true + controller.present(sheet, animated: true) + coordinator.presented = sheet + } else if request == nil, let presented = coordinator.presented { + presented.dismiss(animated: true) + coordinator.presented = nil + } + } +} + +#Preview { + ClientCertificateImportFormSheet(request: nil) +} diff --git a/Sources/App/Settings/Connection/ConnectionSettingsView.swift b/Sources/App/Settings/Connection/ConnectionSettingsView.swift index bba3910f55..40f9d12022 100644 --- a/Sources/App/Settings/Connection/ConnectionSettingsView.swift +++ b/Sources/App/Settings/Connection/ConnectionSettingsView.swift @@ -5,6 +5,7 @@ import SwiftUI /// SwiftUI view for managing server connection settings struct ConnectionSettingsView: View { @StateObject private var viewModel: ConnectionSettingsViewModel + @StateObject private var reauthentication: ServerReauthenticationModel @Environment(\.dismiss) private var dismiss @Environment(\.appSettingsPresenter) private var appSettingsPresenter @State private var shareWrapper: ShareWrapper? @@ -17,8 +18,13 @@ struct ConnectionSettingsView: View { let onDismiss: (() -> Void)? - init(server: Server, onDismiss: (() -> Void)? = nil) { + init( + server: Server, + reauthentication: ServerReauthenticationModel? = nil, + onDismiss: (() -> Void)? = nil + ) { self._viewModel = StateObject(wrappedValue: ConnectionSettingsViewModel(server: server)) + self._reauthentication = StateObject(wrappedValue: reauthentication ?? ServerReauthenticationModel()) self.onDismiss = onDismiss } @@ -28,6 +34,7 @@ struct ConnectionSettingsView: View { clientCertificateSection privacySection statusSection + reauthenticateSection deleteSection } .navigationTitle(L10n.Settings.ConnectionSection.serverDetailsTitle) @@ -301,6 +308,30 @@ struct ConnectionSettingsView: View { appSettingsPresenter?.isSheetPresented = false } + // MARK: - Re-authenticate Section + + private var reauthenticateSection: some View { + Section { + Button { reauthentication.start(for: viewModel.server) } label: { + if reauthentication.isReauthenticating(viewModel.server) { + HStack { + ProgressView() + .progressViewStyle(CircularProgressViewStyle()) + Text(L10n.Settings.ConnectionSection.Reauthenticate.progress) + } + } else { + Text(L10n.Settings.ConnectionSection.Reauthenticate.title) + } + } + .disabled( + reauthentication.isReauthenticating || reauthentication.urlTypes(for: viewModel.server).isEmpty + ) + .serverReauthentication(reauthentication, for: viewModel.server) + } footer: { + Text(L10n.Settings.ConnectionSection.Reauthenticate.footer) + } + } + // MARK: - Delete Section private var deleteSection: some View { @@ -359,6 +390,7 @@ extension ConnectionSettingsView: SettingsScreenSearchable { SettingsSearchEntry(L10n.Settings.ConnectionSection.Websocket.title), SettingsSearchEntry(L10n.SettingsDetails.Notifications.LocalPush.title), SettingsSearchEntry(L10n.Settings.ConnectionSection.Webhook.title), + SettingsSearchEntry(L10n.Settings.ConnectionSection.Reauthenticate.title), SettingsSearchEntry(L10n.Settings.ConnectionSection.DeleteServer.title), ] } diff --git a/Sources/App/Settings/Connection/ConnectionSettingsViewModel.swift b/Sources/App/Settings/Connection/ConnectionSettingsViewModel.swift index 42f3aca0a2..c795be7e3b 100644 --- a/Sources/App/Settings/Connection/ConnectionSettingsViewModel.swift +++ b/Sources/App/Settings/Connection/ConnectionSettingsViewModel.swift @@ -203,7 +203,6 @@ final class ConnectionSettingsViewModel: ObservableObject { private func loadInitialData() { updateFromServerInfo(server.info) updateURLs() - clientCertificate = server.info.connection.clientCertificate guard let api = Current.api(for: server) else { return } api.cachedProfilePicture { [weak self] image in Task { @MainActor [weak self] in @@ -226,6 +225,8 @@ final class ConnectionSettingsViewModel: ObservableObject { private func updateFromServerInfo(_ info: ServerInfo) { serverName = info.name + // Follows the server, so a certificate stored by Re-authenticate shows up without reopening the screen. + clientCertificate = info.connection.clientCertificate // The stored copy re-evaluates the active URL on save, but observers get the value as written, // so evaluate a copy here to keep the highlighted pill right after an edit. var connection = server.info.connection diff --git a/Sources/App/Settings/Connection/ServerReauthenticationModel.swift b/Sources/App/Settings/Connection/ServerReauthenticationModel.swift new file mode 100644 index 0000000000..d31b2b324a --- /dev/null +++ b/Sources/App/Settings/Connection/ServerReauthenticationModel.swift @@ -0,0 +1,134 @@ +import PromiseKit +import Shared +import SwiftUI + +/// Runs a Re-authenticate started from the settings: asks which address to sign in through when there +/// is a choice, presents the login page (and the client certificate import when the server asks for +/// one it was never given), then lets a web view showing the server reload. One model can serve a +/// whole list of servers; the bindings are scoped to the server whose sign in is running, so only the +/// row or button that started it presents anything. +@MainActor +final class ServerReauthenticationModel: ObservableObject { + /// The server whose picker, sign in or error is on screen. + @Published private(set) var activeServerID: Identifier? + @Published private(set) var isReauthenticating = false + @Published var isChoosingURL = false + @Published var loginViewModel: OnboardingAuthLoginViewModel? + @Published var clientCertificateRequest: OnboardingClientCertificateRequest? + @Published var error: Error? + + var reauthenticator = ServerReauthenticator() + + /// The addresses `server` can be signed in through, most preferred first. + func urlTypes(for server: Server) -> [ConnectionInfo.URLType] { + server.info.connection.availableAuthenticationURLTypes + } + + func isReauthenticating(_ server: Server) -> Bool { + isReauthenticating && activeServerID == server.identifier + } + + /// What the Re-authenticate button or menu item does: asks which address to use when there is a + /// choice, otherwise signs in through the only one. + func start(for server: Server) { + guard !isReauthenticating else { return } + let urlTypes = urlTypes(for: server) + if urlTypes.count > 1 { + activeServerID = server.identifier + isChoosingURL = true + } else if let urlType = urlTypes.first { + reauthenticate(server, using: urlType) + } + } + + /// Signs in to `server` again through `urlType` and swaps in the new token, keeping its URLs, + /// webhook and device registration. + func reauthenticate(_ server: Server, using urlType: ConnectionInfo.URLType) { + guard !isReauthenticating else { return } + activeServerID = server.identifier + isReauthenticating = true + error = nil + + reauthenticator.reauthenticate( + server, + using: urlType, + login: { [self] authDetails in + let viewModel = OnboardingAuthLoginViewModel(authDetails: authDetails) + loginViewModel = viewModel + return viewModel.resultPromise.ensure { [weak self] in self?.loginViewModel = nil } + }, + importClientCertificate: { [self] in + Promise { seal in + clientCertificateRequest = OnboardingClientCertificateRequest( + onImport: { [weak self] certificate in + self?.clientCertificateRequest = nil + seal.fulfill(certificate) + }, + onCancel: { [weak self] in + self?.clientCertificateRequest = nil + seal.reject(PMKError.cancelled) + } + ) + } + } + ).done { + Current.Log.info("Re-authenticated server \(server.identifier.rawValue) from settings") + let connection = Current.api(for: server)?.connection + connection?.disconnect() + connection?.connect() + NotificationCenter.default.post(name: ServerReauthenticator.didReauthenticateNotification, object: server) + }.ensure { [weak self] in + self?.isReauthenticating = false + }.catch(policy: .allErrors) { [weak self] error in + if let pmkError = error as? PMKError, pmkError.isCancelled { + Current.Log.info("Re-authentication from settings cancelled by user") + return + } + Current.Log.error("Re-authentication from settings failed: \(error)") + self?.error = error + } + } + + // MARK: - Bindings scoped to one server + + func urlPickerBinding(for server: Server) -> Binding { + Binding( + get: { [self] in isChoosingURL && activeServerID == server.identifier }, + set: { [self] in isChoosingURL = $0 } + ) + } + + func loginBinding(for server: Server) -> Binding { + Binding( + get: { [self] in activeServerID == server.identifier ? loginViewModel : nil }, + set: { [self] in loginViewModel = $0 } + ) + } + + /// The certificate import as a SwiftUI sheet, on iOS. Dismissing it without choosing a certificate + /// cancels the sign in. + func certificateRequestBinding(for server: Server) -> Binding { + Binding( + get: { [self] in Current.isCatalyst ? nil : certificateRequest(for: server) }, + set: { [self] in if $0 == nil { clientCertificateRequest?.cancel() } } + ) + } + + /// The certificate import on Mac Catalyst, where SwiftUI sheet content doesn't receive mouse events + /// reliably, so it is presented as a UIKit form sheet instead. + func certificateFormSheetRequest(for server: Server) -> OnboardingClientCertificateRequest? { + Current.isCatalyst ? certificateRequest(for: server) : nil + } + + private func certificateRequest(for server: Server) -> OnboardingClientCertificateRequest? { + activeServerID == server.identifier ? clientCertificateRequest : nil + } + + /// Dismissing the failure alert clears the error. + func errorBinding(for server: Server) -> Binding { + Binding( + get: { [self] in error != nil && activeServerID == server.identifier }, + set: { [self] in if !$0 { error = nil } } + ) + } +} diff --git a/Sources/App/Settings/Connection/ServerReauthenticationModifier.swift b/Sources/App/Settings/Connection/ServerReauthenticationModifier.swift new file mode 100644 index 0000000000..f38f1c7b71 --- /dev/null +++ b/Sources/App/Settings/Connection/ServerReauthenticationModifier.swift @@ -0,0 +1,43 @@ +import Shared +import SwiftUI + +/// Attaches what a Re-authenticate for `server` presents to the control that starts it: the address +/// picker, the login page, the client certificate import and the failure alert. +struct ServerReauthenticationModifier: ViewModifier { + @ObservedObject var model: ServerReauthenticationModel + let server: Server + + func body(content: Content) -> some View { + content + .confirmationDialog( + L10n.Settings.ConnectionSection.Reauthenticate.urlPickerTitle, + isPresented: model.urlPickerBinding(for: server), + titleVisibility: .visible + ) { + ForEach(model.urlTypes(for: server), id: \.self) { urlType in + Button(urlType.description) { model.reauthenticate(server, using: urlType) } + } + Button(L10n.cancelLabel, role: .cancel, action: {}) + } + .sheet(item: model.loginBinding(for: server)) { OnboardingAuthLoginView(viewModel: $0, style: .modal) } + .sheet(item: model.certificateRequestBinding(for: server)) { + RecoveredServerClientCertificateImportView(request: $0, asSheet: true) + } + .background(ClientCertificateImportFormSheet(request: model.certificateFormSheetRequest(for: server))) + .alert( + L10n.Settings.ConnectionSection.Reauthenticate.errorTitle, + isPresented: model.errorBinding(for: server), + presenting: model.error + ) { _ in + Button(L10n.okLabel, role: .cancel, action: {}) + } message: { error in + Text(error.localizedDescription) + } + } +} + +extension View { + func serverReauthentication(_ model: ServerReauthenticationModel, for server: Server) -> some View { + modifier(ServerReauthenticationModifier(model: model, server: server)) + } +} diff --git a/Sources/App/Settings/Settings/ServersListView.swift b/Sources/App/Settings/Settings/ServersListView.swift index f729437eb8..7cfa068f7a 100644 --- a/Sources/App/Settings/Settings/ServersListView.swift +++ b/Sources/App/Settings/Settings/ServersListView.swift @@ -9,6 +9,7 @@ struct ServersListView: View { var macSidebarSelection: MacSettingsSidebarSelection? @StateObject private var observer = ServersObserver() + @StateObject private var reauthentication = ServerReauthenticationModel() @State private var showAddServer = false @State private var serverPendingDeletion: Server? @Environment(\.editMode) private var editMode @@ -26,6 +27,10 @@ struct ServersListView: View { } label: { Label(L10n.Settings.ConnectionSection.refreshServer, systemSymbol: .arrowClockwise) } + Button { reauthentication.start(for: server) } label: { + Label(L10n.Settings.ConnectionSection.Reauthenticate.title, systemSymbol: .personBadgeKey) + } + .disabled(reauthentication.isReauthenticating || reauthentication.urlTypes(for: server).isEmpty) if observer.servers.first?.identifier != server.identifier { Button { observer.makeDefault(server) @@ -70,6 +75,7 @@ struct ServersListView: View { } message: { Text(L10n.Settings.ConnectionSection.DeleteServer.message) } + .serverReauthentication(reauthentication, for: server) } .onMove { source, destination in observer.moveServers(from: source, to: destination) diff --git a/Sources/HANetworking/Sources/TokenManager.swift b/Sources/HANetworking/Sources/TokenManager.swift index cf45438ecd..054e359ee5 100644 --- a/Sources/HANetworking/Sources/TokenManager.swift +++ b/Sources/HANetworking/Sources/TokenManager.swift @@ -280,9 +280,15 @@ public final class TokenManager: @unchecked Sendable { let promise: Promise = firstly { authenticationAPI.refreshTokenWith(tokenInfo: tokenInfo) - }.get { [server] tokenInfo in + }.get { [server, tokenInfo] refreshed in + // Re-authenticating while this refresh was in flight stored a new session; writing this + // answer would put the previous one back. + guard server.info.token.refreshToken == tokenInfo.refreshToken else { + HANetworkingEnvironment.current.log.info("not storing refresh token, the session was replaced") + return + } HANetworkingEnvironment.current.log.info("storing refresh token") - server.info.token = tokenInfo + server.info.token = refreshed }.tap(on: refreshPromiseCache.queue) { [self, tokenInfo] result in // On the cache's own queue, and ahead of the `ensure` that clears it, so the refusal is // recorded before any caller can be handed a cleared cache: one that arrived in between @@ -294,13 +300,16 @@ public final class TokenManager: @unchecked Sendable { }.ensure(on: refreshPromiseCache.queue) { [self] in HANetworkingEnvironment.current.log.info("reset cached refreshToken promise") refreshPromiseCache.promise = nil - }.tap { [server] result in + }.tap { [server, tokenInfo] result in switch result { case let .rejected(error): HANetworkingEnvironment.current.log.error("refresh token got error: \(error)") + // A refusal of a session that re-authenticating has since replaced says nothing + // about the new one, so it must not send the user back to the login. if let underlying = error.authenticationAPIError, - underlying.shouldRequireReauthentication { + underlying.shouldRequireReauthentication, + server.info.token.refreshToken == tokenInfo.refreshToken { /// Server rejected the refresh token. All is lost. HACore performs the actual /// client-event log + unsubscribe + disconnect + onboarding-needed via this seam. HANetworkingEnvironment.current.handleReauthenticationRequired( diff --git a/Sources/Shared/Resources/Swiftgen/Strings.swift b/Sources/Shared/Resources/Swiftgen/Strings.swift index 9002a3284c..fceb0fd14d 100644 --- a/Sources/Shared/Resources/Swiftgen/Strings.swift +++ b/Sources/Shared/Resources/Swiftgen/Strings.swift @@ -5611,6 +5611,18 @@ public enum L10n { /// No URL public static var title: String { return L10n.tr("Localizable", "settings.connection_section.no_base_url.title") } } + public enum Reauthenticate { + /// Couldn't re-authenticate + public static var errorTitle: String { return L10n.tr("Localizable", "settings.connection_section.reauthenticate.error_title") } + /// Sign in to this server again, for example after changing your password or to use a different user. Your server's URLs and settings stay as they are. + public static var footer: String { return L10n.tr("Localizable", "settings.connection_section.reauthenticate.footer") } + /// Re-authenticating… + public static var progress: String { return L10n.tr("Localizable", "settings.connection_section.reauthenticate.progress") } + /// Re-authenticate + public static var title: String { return L10n.tr("Localizable", "settings.connection_section.reauthenticate.title") } + /// Sign in using + public static var urlPickerTitle: String { return L10n.tr("Localizable", "settings.connection_section.reauthenticate.url_picker_title") } + } public enum RemoteUiUrl { /// Remote UI URL public static var title: String { return L10n.tr("Localizable", "settings.connection_section.remote_ui_url.title") } diff --git a/Tests/App/Container/OnboardingStateObservableTests.swift b/Tests/App/Container/OnboardingStateObservableTests.swift index 44a4a825ae..aa22e352c2 100644 --- a/Tests/App/Container/OnboardingStateObservableTests.swift +++ b/Tests/App/Container/OnboardingStateObservableTests.swift @@ -109,8 +109,8 @@ final class OnboardingStateObservableTests: XCTestCase { let probe = ReauthProbe() let token = newToken let state = OnboardingStateObservable() - state.clientCertificateRequirement = { _ in requiresCertificate } - state.fetchToken = { _, _, _, certificate in + state.reauthenticator.clientCertificateRequirement = { _ in requiresCertificate } + state.reauthenticator.fetchToken = { _, _, _, certificate in probe.fetchedCertificate = certificate return .value(token) } diff --git a/Tests/App/Container/RecoveredServerReauthViewModelTests.swift b/Tests/App/Container/RecoveredServerReauthViewModelTests.swift index ddaa2ee325..b4165f29e9 100644 --- a/Tests/App/Container/RecoveredServerReauthViewModelTests.swift +++ b/Tests/App/Container/RecoveredServerReauthViewModelTests.swift @@ -127,7 +127,7 @@ final class RecoveredServerReauthViewModelTests: XCTestCase { } func testReauthenticateAsksForTheCertificateThenLogsIn() throws { - state.clientCertificateRequirement = { _ in .value(true) } + state.reauthenticator.clientCertificateRequirement = { _ in .value(true) } let completed = expectation(description: "completed") viewModel.reauthenticate(using: .external) { result in diff --git a/Tests/App/Container/ServerReauthenticatorTests.swift b/Tests/App/Container/ServerReauthenticatorTests.swift new file mode 100644 index 0000000000..bfdb01c741 --- /dev/null +++ b/Tests/App/Container/ServerReauthenticatorTests.swift @@ -0,0 +1,135 @@ +import Foundation +@testable import HomeAssistant +import PromiseKit +@testable import Shared +import Testing + +/// Covers logging an existing server in again: the new token replaces the old one, the address only +/// moves when the login page was redirected on the same host, and a server without the chosen +/// address fails before any login is shown. +@Suite(.serialized) +@MainActor +struct ServerReauthenticatorTests { + private let newToken = TokenInfo(accessToken: "new-access", refreshToken: "new-refresh", expiration: .distantFuture) + + private func makeServer(externalURL: String? = "https://external.example.com") -> Server { + let info = ServerInfo( + name: "Test Server", + connection: .init( + externalURL: externalURL.flatMap(URL.init(string:)), + internalURL: nil, + cloudhookURL: nil, + remoteUIURL: nil, + webhookID: "webhook-id", + webhookSecret: nil, + internalSSIDs: nil, + internalHardwareAddresses: nil, + isLocalPushEnabled: false, + securityExceptions: .init(), + connectionAccessSecurityLevel: .mostSecure + ), + token: .init(accessToken: "old-access", refreshToken: "old-refresh", expiration: Date()), + version: "2024.1" + ) + return Server.fake(identifier: .init(rawValue: "reauth-server"), initial: info) + } + + private func makeReauthenticator(fetchedBaseURL: @escaping (URL) -> Void = { _ in }) -> ServerReauthenticator { + var reauthenticator = ServerReauthenticator() + reauthenticator.clientCertificateRequirement = { _ in .value(false) } + let token = newToken + reauthenticator.fetchToken = { _, baseURL, _, _ in + fetchedBaseURL(baseURL) + return .value(token) + } + return reauthenticator + } + + private func result(of promise: Promise) async -> Swift.Result { + await withCheckedContinuation { continuation in + promise.done { continuation.resume(returning: .success(())) } + .catch(policy: .allErrors) { continuation.resume(returning: .failure($0)) } + } + } + + @Test func storesTheNewTokenAndKeepsTheAddress() async throws { + let server = makeServer() + var fetchedBaseURL: URL? + let reauthenticator = makeReauthenticator { fetchedBaseURL = $0 } + + let outcome = await result(of: reauthenticator.reauthenticate( + server, + using: .external, + login: { details in + #expect(details.url.host == "external.example.com") + return .value(.init(code: "code", resolvedURL: nil)) + }, + importClientCertificate: { Promise(error: PMKError.cancelled) } + )) + + try outcome.get() + #expect(server.info.token == newToken) + #expect(fetchedBaseURL == URL(string: "https://external.example.com")) + #expect(server.info.connection.address(for: .external) == URL(string: "https://external.example.com")) + } + + @Test func aSameHostRedirectMovesTheAddressItSignedInThrough() async throws { + let server = makeServer() + var fetchedBaseURL: URL? + let reauthenticator = makeReauthenticator { fetchedBaseURL = $0 } + let redirected = URL(string: "https://external.example.com:8443/auth/authorize?client_id=x") + + let outcome = await result(of: reauthenticator.reauthenticate( + server, + using: .external, + login: { _ in .value(.init(code: "code", resolvedURL: redirected)) }, + importClientCertificate: { Promise(error: PMKError.cancelled) } + )) + + try outcome.get() + #expect(fetchedBaseURL == URL(string: "https://external.example.com:8443")) + #expect(server.info.connection.address(for: .external) == URL(string: "https://external.example.com:8443")) + #expect(server.info.token == newToken) + } + + @Test func aMissingAddressFailsWithoutShowingTheLogin() async { + let server = makeServer(externalURL: nil) + var loginShown = false + + let outcome = await result(of: makeReauthenticator().reauthenticate( + server, + using: .external, + login: { _ in + loginShown = true + return .value(.init(code: "code", resolvedURL: nil)) + }, + importClientCertificate: { Promise(error: PMKError.cancelled) } + )) + + guard case let .failure(error) = outcome else { + Issue.record("Expected the re-authentication to fail") + return + } + #expect(error is ServerConnectionError) + #expect(!loginShown) + #expect(server.info.token.refreshToken == "old-refresh") + } + + @Test func cancellingTheLoginKeepsTheOldToken() async { + let server = makeServer() + + let outcome = await result(of: makeReauthenticator().reauthenticate( + server, + using: .external, + login: { _ in Promise(error: PMKError.cancelled) }, + importClientCertificate: { Promise(error: PMKError.cancelled) } + )) + + guard case let .failure(error) = outcome else { + Issue.record("Expected the re-authentication to be cancelled") + return + } + #expect((error as? PMKError)?.isCancelled == true) + #expect(server.info.token.refreshToken == "old-refresh") + } +} diff --git a/Tests/App/Settings/ConnectionSettingsView.test.swift b/Tests/App/Settings/ConnectionSettingsView.test.swift index 5dba497b57..cf47ada8a4 100644 --- a/Tests/App/Settings/ConnectionSettingsView.test.swift +++ b/Tests/App/Settings/ConnectionSettingsView.test.swift @@ -1,6 +1,7 @@ import CoreLocation import Foundation @testable import HomeAssistant +import PromiseKit @testable import Shared import SwiftUI import Testing @@ -11,6 +12,28 @@ import Testing @MainActor struct ConnectionSettingsViewTests { @Test func serverConnectionScreen() async throws { + try await assertServerConnectionScreen() + } + + @Test func settingsSearchFindsReauthenticate() { + #expect(ConnectionSettingsView.settingsSearchEntries.contains { $0.matches(searchQuery: "re-auth") }) + } + + /// While a Re-authenticate runs, its row shows progress instead of the button title. + @Test func serverConnectionScreenWhileReauthenticating() async throws { + let reauthentication = ServerReauthenticationModel() + // The sign in stays on its first step, so the row stays busy without presenting anything. + reauthentication.reauthenticator.clientCertificateRequirement = { _ in Promise.pending().promise } + try await assertServerConnectionScreen(reauthentication: reauthentication) { server in + reauthentication.reauthenticate(server, using: .external) + } + } + + private func assertServerConnectionScreen( + reauthentication: ServerReauthenticationModel? = nil, + prepare: (Server) -> Void = { _ in }, + testName: String = #function + ) async throws { let info = ServerInfo( name: "Test Server", connection: .init( @@ -41,12 +64,15 @@ struct ConnectionSettingsViewTests { // The Device Name row falls back to this device's name, which differs per machine. Current.device.deviceName = { "iPhone" } + prepare(server) + assertLightDarkSnapshots( - of: NavigationView { ConnectionSettingsView(server: server) }, + of: NavigationView { ConnectionSettingsView(server: server, reauthentication: reauthentication) }, drawHierarchyInKeyWindow: true, // Taller than a device so the whole details section is captured, not only the part // that fits above the fold. - layout: .fixed(width: 390, height: 2600) + layout: .fixed(width: 390, height: 2600), + testName: testName ) } } diff --git a/Tests/App/Settings/ConnectionSettingsViewModel.test.swift b/Tests/App/Settings/ConnectionSettingsViewModel.test.swift index 5e3f45a98f..f40fb533ea 100644 --- a/Tests/App/Settings/ConnectionSettingsViewModel.test.swift +++ b/Tests/App/Settings/ConnectionSettingsViewModel.test.swift @@ -226,6 +226,23 @@ struct ConnectionSettingsViewModelTests { } } + /// A certificate stored outside this screen, as Re-authenticate does, shows up while it is open. + @Test func clientCertificateFollowsTheServer() async { + await withTestWorld { + let server = makeServer(internalURL: nil, externalURL: "https://external.example.com") + let viewModel = ConnectionSettingsViewModel(server: server) + #expect(viewModel.clientCertificate == nil) + + let certificate = ClientCertificate(keychainIdentifier: "stored", displayName: "Stored") + server.update { $0.connection.clientCertificate = certificate } + + for _ in 0 ..< 3000 where viewModel.clientCertificate == nil { + try? await Task.sleep(for: .milliseconds(10)) + } + #expect(viewModel.clientCertificate == certificate) + } + } + @Test func invitationURLNeedsAnAddressToInviteTo() async { await withTestWorld { let shareable = ConnectionSettingsViewModel( diff --git a/Tests/App/Settings/ServerReauthenticationModel.test.swift b/Tests/App/Settings/ServerReauthenticationModel.test.swift new file mode 100644 index 0000000000..cd457c43f8 --- /dev/null +++ b/Tests/App/Settings/ServerReauthenticationModel.test.swift @@ -0,0 +1,242 @@ +import Foundation +@testable import HomeAssistant +import PromiseKit +@testable import Shared +import Testing + +/// Covers a Re-authenticate started from the settings: which address it signs in through, what it +/// presents and for which server, and what a cancel, a failure or a success leaves behind. +@Suite(.serialized) +@MainActor +struct ServerReauthenticationModelTests { + private let newToken = TokenInfo(accessToken: "new-access", refreshToken: "new-refresh", expiration: .distantFuture) + private let certificate = ClientCertificate(keychainIdentifier: "imported", displayName: "Imported") + + private func makeServer( + identifier: String = "test-server", + internalURL: String? = nil, + externalURL: String? = "https://external.example.com" + ) -> Server { + let info = ServerInfo( + name: "Test Server", + connection: .init( + externalURL: externalURL.flatMap(URL.init(string:)), + internalURL: internalURL.flatMap(URL.init(string:)), + cloudhookURL: nil, + remoteUIURL: nil, + webhookID: "webhook-id", + webhookSecret: nil, + internalSSIDs: nil, + internalHardwareAddresses: nil, + isLocalPushEnabled: false, + securityExceptions: .init(), + connectionAccessSecurityLevel: .lessSecure + ), + token: .init(accessToken: "old-access", refreshToken: "old-refresh", expiration: Date()), + version: "2024.1" + ) + return Server.fake(identifier: .init(rawValue: identifier), initial: info) + } + + private func makeModel(requiresCertificate: Bool = false) -> ServerReauthenticationModel { + let model = ServerReauthenticationModel() + model.reauthenticator.clientCertificateRequirement = { _ in .value(requiresCertificate) } + let token = newToken + model.reauthenticator.fetchToken = { _, _, _, _ in .value(token) } + return model + } + + /// Answers the login page the way the server's OAuth callback does. + private func completeLogin(_ loginViewModel: OnboardingAuthLoginViewModel?) { + guard let loginViewModel else { return } + loginViewModel.webView( + loginViewModel.webView, + decidePolicyFor: FakeWKNavigationAction( + request: URLRequest(url: URL(string: "homeassistant://auth-callback?code=code_123")!) + ), + decisionHandler: { _ in } + ) + } + + /// Waits for `condition`, failing the test where it was called if it never holds. + private func expectEventually( + _ condition: () -> Bool, + sourceLocation: SourceLocation = #_sourceLocation + ) async { + for _ in 0 ..< 3000 { + if condition() { + return + } + try? await Task.sleep(for: .milliseconds(10)) + } + #expect(condition(), sourceLocation: sourceLocation) + } + + @Test func aChoiceOfAddressesAsksWhichOneForThatServerOnly() { + let model = makeModel() + let server = makeServer(internalURL: "http://internal.example.com:8123") + let otherServer = makeServer(identifier: "other-server") + + #expect(model.urlTypes(for: server) == [.external, .internal]) + model.start(for: server) + + #expect(model.urlPickerBinding(for: server).wrappedValue) + #expect(!model.urlPickerBinding(for: otherServer).wrappedValue) + #expect(!model.isReauthenticating) + + model.urlPickerBinding(for: server).wrappedValue = false + #expect(!model.isChoosingURL) + } + + @Test func aSingleAddressGoesStraightToTheLogin() async { + let model = makeModel() + let server = makeServer() + let otherServer = makeServer(identifier: "other-server") + + model.start(for: server) + + #expect(!model.isChoosingURL) + #expect(model.isReauthenticating(server)) + #expect(!model.isReauthenticating(otherServer)) + await expectEventually { model.loginBinding(for: server).wrappedValue != nil } + #expect(model.loginBinding(for: otherServer).wrappedValue == nil) + #expect(model.loginViewModel?.authDetails.url.host == "external.example.com") + + model.loginBinding(for: server).wrappedValue?.cancel() + await expectEventually { !model.isReauthenticating } + } + + @Test func aServerWithoutAnAddressDoesNothing() { + let model = makeModel() + let server = makeServer(externalURL: nil) + + model.start(for: server) + + #expect(model.urlTypes(for: server).isEmpty) + #expect(!model.isChoosingURL) + #expect(!model.isReauthenticating) + } + + @Test func cancellingTheLoginLeavesTheTokenAndShowsNoError() async { + let model = makeModel() + let server = makeServer() + + model.reauthenticate(server, using: .external) + await expectEventually { model.loginViewModel != nil } + let loginViewModel = model.loginViewModel + // Dismissing the sheet clears the binding; the login page cancels as it disappears. + model.loginBinding(for: server).wrappedValue = nil + loginViewModel?.cancelIfUnresolved() + + await expectEventually { !model.isReauthenticating } + #expect(model.loginViewModel == nil) + #expect(model.error == nil) + #expect(server.info.token.refreshToken == "old-refresh") + } + + @Test func signingInSwapsTheTokenAndTellsTheWebView() async { + let model = makeModel() + let server = makeServer() + var notifiedServerID: String? + let observer = NotificationCenter.default.addObserver( + forName: ServerReauthenticator.didReauthenticateNotification, + object: nil, + queue: nil + ) { notifiedServerID = ($0.object as? Server)?.identifier.rawValue } + defer { NotificationCenter.default.removeObserver(observer) } + + model.start(for: server) + // A second tap while the first sign in runs is ignored rather than opening another login. + model.start(for: server) + model.reauthenticate(server, using: .external) + await expectEventually { model.loginViewModel != nil } + completeLogin(model.loginViewModel) + + await expectEventually { !model.isReauthenticating } + #expect(model.loginViewModel == nil) + #expect(model.error == nil) + #expect(server.info.token == newToken) + #expect(notifiedServerID == server.identifier.rawValue) + } + + @Test func aServerAskingForACertificateGetsOneBeforeTheLogin() async { + let model = makeModel(requiresCertificate: true) + let server = makeServer() + let otherServer = makeServer(identifier: "other-server") + + model.reauthenticate(server, using: .external) + await expectEventually { model.certificateRequestBinding(for: server).wrappedValue != nil } + #expect(model.certificateRequestBinding(for: otherServer).wrappedValue == nil) + #expect(model.loginViewModel == nil) + + model.clientCertificateRequest?.complete(with: certificate) + await expectEventually { model.loginViewModel != nil } + #expect(model.clientCertificateRequest == nil) + #expect(model.loginViewModel?.authDetails.clientCertificate == certificate) + completeLogin(model.loginViewModel) + + await expectEventually { !model.isReauthenticating } + #expect(model.error == nil) + #expect(server.info.token == newToken) + #expect(server.info.connection.clientCertificate == certificate) + } + + @Test func theCertificateImportIsAFormSheetOnlyOnCatalyst() async { + let previousIsCatalyst = Current.isCatalyst + defer { Current.isCatalyst = previousIsCatalyst } + let model = makeModel(requiresCertificate: true) + let server = makeServer() + + model.reauthenticate(server, using: .external) + await expectEventually { model.clientCertificateRequest != nil } + + Current.isCatalyst = false + #expect(model.certificateRequestBinding(for: server).wrappedValue != nil) + #expect(model.certificateFormSheetRequest(for: server) == nil) + + Current.isCatalyst = true + #expect(model.certificateRequestBinding(for: server).wrappedValue == nil) + #expect(model.certificateFormSheetRequest(for: server) != nil) + #expect(model.certificateFormSheetRequest(for: makeServer(identifier: "other-server")) == nil) + + model.clientCertificateRequest?.cancel() + await expectEventually { !model.isReauthenticating } + } + + @Test func dismissingTheCertificateImportCancelsWithoutAnError() async { + let model = makeModel(requiresCertificate: true) + let server = makeServer() + + model.reauthenticate(server, using: .external) + await expectEventually { model.clientCertificateRequest != nil } + model.certificateRequestBinding(for: server).wrappedValue = nil + + await expectEventually { !model.isReauthenticating } + #expect(model.clientCertificateRequest == nil) + #expect(model.loginViewModel == nil) + #expect(model.error == nil) + #expect(server.info.token.refreshToken == "old-refresh") + } + + @Test func aFailureIsShownForThatServerUntilDismissed() async { + let model = makeModel() + model.reauthenticator.fetchToken = { _, _, _, _ in Promise(error: URLError(.badServerResponse)) } + let server = makeServer() + let otherServer = makeServer(identifier: "other-server") + + model.reauthenticate(server, using: .external) + await expectEventually { model.loginViewModel != nil } + completeLogin(model.loginViewModel) + + await expectEventually { model.error != nil } + #expect(!model.isReauthenticating) + #expect(model.errorBinding(for: server).wrappedValue) + #expect(!model.errorBinding(for: otherServer).wrappedValue) + #expect(server.info.token.refreshToken == "old-refresh") + + model.errorBinding(for: server).wrappedValue = true + #expect(model.error != nil) + model.errorBinding(for: server).wrappedValue = false + #expect(model.error == nil) + } +} diff --git a/Tests/App/Settings/ServerReauthenticationModifier.test.swift b/Tests/App/Settings/ServerReauthenticationModifier.test.swift new file mode 100644 index 0000000000..7651fce4bf --- /dev/null +++ b/Tests/App/Settings/ServerReauthenticationModifier.test.swift @@ -0,0 +1,159 @@ +import Foundation +@testable import HomeAssistant +import PromiseKit +@testable import Shared +import SwiftUI +import Testing +import UIKit + +/// Hosts a control carrying `.serverReauthentication` in a key window and checks that each step of +/// a Re-authenticate is actually presented from it: the address picker, the client certificate +/// import, the login page and the failure alert. +@Suite(.serialized) +@MainActor +struct ServerReauthenticationModifierTests { + private func makeServer(internalURL: String? = nil) -> Server { + let info = ServerInfo( + name: "Test Server", + connection: .init( + externalURL: URL(string: "https://external.example.com"), + internalURL: internalURL.flatMap(URL.init(string:)), + cloudhookURL: nil, + remoteUIURL: nil, + webhookID: "webhook-id", + webhookSecret: nil, + internalSSIDs: nil, + internalHardwareAddresses: nil, + isLocalPushEnabled: false, + securityExceptions: .init(), + connectionAccessSecurityLevel: .lessSecure + ), + token: .init(accessToken: "old-access", refreshToken: "old-refresh", expiration: Date()), + version: "2024.1" + ) + return Server.fake(identifier: .init(rawValue: "modifier-server"), initial: info) + } + + private func host(_ model: ServerReauthenticationModel, for server: Server) -> (UIWindow, UIViewController) { + let window = UIWindow(frame: .init(x: 0, y: 0, width: 390, height: 844)) + let controller = UIHostingController( + rootView: Text(verbatim: "Re-authenticate").serverReauthentication(model, for: server) + ) + window.rootViewController = controller + window.makeKeyAndVisible() + window.layoutIfNeeded() + return (window, controller) + } + + /// Waits for `condition`, failing the test where it was called if it never holds. + private func expectEventually( + _ condition: () -> Bool, + sourceLocation: SourceLocation = #_sourceLocation + ) async { + for _ in 0 ..< 3000 { + if condition() { + return + } + try? await Task.sleep(for: .milliseconds(10)) + } + #expect(condition(), sourceLocation: sourceLocation) + } + + private func waitUntil(_ condition: () -> Bool) async -> Bool { + for _ in 0 ..< 3000 { + if condition() { + return true + } + try? await Task.sleep(for: .milliseconds(10)) + } + return condition() + } + + private func dismissPresented(_ controller: UIViewController) async { + controller.dismiss(animated: false) + _ = await waitUntil { controller.presentedViewController == nil } + } + + @Test func theAddressPickerIsPresentedFromTheControl() async { + let server = makeServer(internalURL: "http://internal.example.com:8123") + let model = ServerReauthenticationModel() + let (window, controller) = host(model, for: server) + defer { window.isHidden = true } + + model.start(for: server) + + await expectEventually { controller.presentedViewController != nil } + model.isChoosingURL = false + await dismissPresented(controller) + } + + @Test func theCertificateImportThenTheLoginArePresented() async { + let server = makeServer() + let model = ServerReauthenticationModel() + model.reauthenticator.clientCertificateRequirement = { _ in .value(true) } + let (window, controller) = host(model, for: server) + defer { window.isHidden = true } + + model.reauthenticate(server, using: .external) + await expectEventually { controller.presentedViewController != nil } + #expect(model.clientCertificateRequest != nil) + + model.clientCertificateRequest?.complete(with: ClientCertificate( + keychainIdentifier: "imported", + displayName: "Imported" + )) + await expectEventually { model.loginViewModel != nil } + await expectEventually { + controller.presentedViewController != nil && model.clientCertificateRequest == nil + } + + model.loginViewModel?.cancel() + await expectEventually { !model.isReauthenticating } + await dismissPresented(controller) + } + + /// On Mac Catalyst SwiftUI sheet content doesn't receive mouse events reliably, so the import is a + /// UIKit form sheet there, dismissed once a certificate is chosen. + @Test func onCatalystTheCertificateImportIsAFormSheet() async { + let previousIsCatalyst = Current.isCatalyst + Current.isCatalyst = true + defer { Current.isCatalyst = previousIsCatalyst } + let server = makeServer() + let model = ServerReauthenticationModel() + model.reauthenticator.clientCertificateRequirement = { _ in .value(true) } + let (window, controller) = host(model, for: server) + defer { window.isHidden = true } + + model.reauthenticate(server, using: .external) + await expectEventually { model.clientCertificateRequest != nil } + #expect(model.certificateRequestBinding(for: server).wrappedValue == nil) + await expectEventually { controller.presentedViewController?.modalPresentationStyle == .formSheet } + let formSheet = controller.presentedViewController + + model.clientCertificateRequest?.complete(with: ClientCertificate( + keychainIdentifier: "imported", + displayName: "Imported" + )) + await expectEventually { model.loginViewModel != nil } + await expectEventually { controller.presentedViewController !== formSheet } + + model.loginViewModel?.cancel() + await expectEventually { !model.isReauthenticating } + await dismissPresented(controller) + } + + @Test func aFailureIsPresentedAsAnAlert() async { + let server = makeServer() + let model = ServerReauthenticationModel() + model.reauthenticator.clientCertificateRequirement = { _ in Promise(error: URLError(.notConnectedToInternet)) } + let (window, controller) = host(model, for: server) + defer { window.isHidden = true } + + model.reauthenticate(server, using: .external) + + await expectEventually { model.error != nil } + await expectEventually { controller.presentedViewController is UIAlertController } + model.error = nil + await dismissPresented(controller) + } +} diff --git a/Tests/App/Settings/__Snapshots__/ConnectionSettingsView.test/serverConnectionScreen.dark.png b/Tests/App/Settings/__Snapshots__/ConnectionSettingsView.test/serverConnectionScreen.dark.png index c4e0779b04..3ad2560fcc 100644 Binary files a/Tests/App/Settings/__Snapshots__/ConnectionSettingsView.test/serverConnectionScreen.dark.png and b/Tests/App/Settings/__Snapshots__/ConnectionSettingsView.test/serverConnectionScreen.dark.png differ diff --git a/Tests/App/Settings/__Snapshots__/ConnectionSettingsView.test/serverConnectionScreen.light.png b/Tests/App/Settings/__Snapshots__/ConnectionSettingsView.test/serverConnectionScreen.light.png index 4dd18ef048..aa27986996 100644 Binary files a/Tests/App/Settings/__Snapshots__/ConnectionSettingsView.test/serverConnectionScreen.light.png and b/Tests/App/Settings/__Snapshots__/ConnectionSettingsView.test/serverConnectionScreen.light.png differ diff --git a/Tests/App/Settings/__Snapshots__/ConnectionSettingsView.test/serverConnectionScreenWhileReauthenticating.dark.png b/Tests/App/Settings/__Snapshots__/ConnectionSettingsView.test/serverConnectionScreenWhileReauthenticating.dark.png new file mode 100644 index 0000000000..d94241bd67 Binary files /dev/null and b/Tests/App/Settings/__Snapshots__/ConnectionSettingsView.test/serverConnectionScreenWhileReauthenticating.dark.png differ diff --git a/Tests/App/Settings/__Snapshots__/ConnectionSettingsView.test/serverConnectionScreenWhileReauthenticating.light.png b/Tests/App/Settings/__Snapshots__/ConnectionSettingsView.test/serverConnectionScreenWhileReauthenticating.light.png new file mode 100644 index 0000000000..8bdd2ccfc4 Binary files /dev/null and b/Tests/App/Settings/__Snapshots__/ConnectionSettingsView.test/serverConnectionScreenWhileReauthenticating.light.png differ diff --git a/Tests/App/WebView/WebViewControllerTests.swift b/Tests/App/WebView/WebViewControllerTests.swift index f8e39ab7c9..6b17eaa455 100644 --- a/Tests/App/WebView/WebViewControllerTests.swift +++ b/Tests/App/WebView/WebViewControllerTests.swift @@ -61,6 +61,30 @@ final class WebViewControllerTests: XCTestCase { XCTAssertEqual(overlayState.emptyState?.server.identifier, server.identifier) } + /// Re-authenticating from the server's settings has to bring a logged-out web view back, while a + /// re-authentication of some other server leaves it alone. + func testReauthenticationFromSettingsLeavesTheLoggedOutState() async { + let server = Server.fake() + let sut = makeSUT(server: server) + sut.webView = WKWebView(frame: .zero) + sut.overlayState = WebFrontendOverlayState() + sut.observeConnectionNotifications() + sut.showLoggedOutState() + + NotificationCenter.default.post( + name: ServerReauthenticator.didReauthenticateNotification, + object: Server.fake(identifier: .init(rawValue: "other-server")) + ) + await Task.yield() + XCTAssertTrue(sut.didLogOut) + + NotificationCenter.default.post(name: ServerReauthenticator.didReauthenticateNotification, object: server) + await waitUntil({ !sut.didLogOut }) + + XCTAssertFalse(sut.didLogOut) + XCTAssertEqual(sut.connectionState, .unknown) + } + func testUpdateFrontendConnectionStateDoesNotDowngradeAuthInvalidToDisconnected() { let sut = makeSUT() sut.connectionState = .authInvalid diff --git a/Tests/Shared/TokenManagerRefreshTokenRejection.test.swift b/Tests/Shared/TokenManagerRefreshTokenRejection.test.swift index 30a30adb57..288c925a1b 100644 --- a/Tests/Shared/TokenManagerRefreshTokenRejection.test.swift +++ b/Tests/Shared/TokenManagerRefreshTokenRejection.test.swift @@ -123,6 +123,38 @@ class TokenManagerRefreshTokenRejectionTests: XCTestCase { XCTAssertEqual(recorder.lastSentRefreshToken, "ReauthenticatedRefreshToken") } + /// Re-authenticating while a refresh of the previous session is in flight stores a new session; + /// the refresh answering afterwards must not put the previous one back. + func testRefreshFinishingAfterReauthenticationKeepsTheNewSession() async { + stubRefreshToken(.success, delay: 0.5) + let server = Self.serverWithExpiredToken() + let tokenManager = TokenManager(server: server) + + let refresh = tokenManager.bearerToken + await waitForRequest() + server.update { $0.token = Self.reauthenticatedToken } + _ = await settle(refresh) + + XCTAssertEqual(server.info.token, Self.reauthenticatedToken) + } + + /// The previous session being refused after the user already signed in again says nothing about + /// the new one, so it must not send them back to the login. + func testRefusalArrivingAfterReauthenticationDoesNotRequestReauthentication() async { + stubRefreshToken(.rejection, delay: 0.5) + let server = Self.serverWithExpiredToken() + let tokenManager = TokenManager(server: server) + + let refresh = tokenManager.bearerToken + await waitForRequest() + server.update { $0.token = Self.reauthenticatedToken } + _ = await settle(refresh) + + await waitForMainQueue() + XCTAssertEqual(recorder.reauthenticationCount, 0) + XCTAssertEqual(server.info.token, Self.reauthenticatedToken) + } + /// Logging out revokes the refresh token along with the access token, so refreshing is as dead as /// re-sending the access token — and the app keeps running until the user signs back in. func testRevokedRefreshTokenIsNeverSent() async { @@ -224,6 +256,22 @@ class TokenManagerRefreshTokenRejectionTests: XCTestCase { }) } + private static let reauthenticatedToken = TokenInfo( + accessToken: "ReauthenticatedAccessToken", + refreshToken: "ReauthenticatedRefreshToken", + expiration: Current.date().addingTimeInterval(1800) + ) + + /// Waits until the refresh request reached the stub, so a token stored afterwards lands while it + /// is in flight. + private func waitForRequest() async { + let deadline = Date(timeIntervalSinceNow: 30) + while recorder.requestCount == 0, Date() < deadline { + try? await Task.sleep(for: .milliseconds(10)) + } + XCTAssertEqual(recorder.requestCount, 1) + } + private enum RefreshOutcome { /// What Home Assistant answers for a refresh token it no longer has. case rejection @@ -231,7 +279,7 @@ class TokenManagerRefreshTokenRejectionTests: XCTestCase { case success } - private func stubRefreshToken(_ outcome: RefreshOutcome) { + private func stubRefreshToken(_ outcome: RefreshOutcome, delay: TimeInterval = 0) { if let stubDescriptor { HTTPStubs.removeStub(stubDescriptor) } @@ -239,26 +287,29 @@ class TokenManagerRefreshTokenRejectionTests: XCTestCase { stubDescriptor = stub(condition: { $0.url?.path == "/auth/token" }, response: { [recorder] request in recorder.recordRequest(body: request.ohhttpStubs_httpBody) + let response: HTTPStubsResponse switch outcome { case .rejection: - return HTTPStubsResponse( + response = HTTPStubsResponse( jsonObject: ["error": "invalid_grant"], statusCode: 400, headers: nil ) case .serverError: - return HTTPStubsResponse( + response = HTTPStubsResponse( jsonObject: ["error": "internal_error"], statusCode: 500, headers: nil ) case .success: - return HTTPStubsResponse( + response = HTTPStubsResponse( jsonObject: ["access_token": "RefreshedAccessToken", "expires_in": 1800], statusCode: 200, headers: nil ) } + // Holds the answer back, for a refresh still in flight while something else happens. + return response.requestTime(0, responseTime: delay) }) }