From c70f84b489175cd565c4dae9588a8466868f505b Mon Sep 17 00:00:00 2001 From: Milen Pivchev Date: Wed, 18 Feb 2026 18:01:54 +0100 Subject: [PATCH 1/5] WIP Signed-off-by: Milen Pivchev --- Brand/Intro/NCIntroViewController.swift | 9 +- iOSClient/Login/NCLogin.swift | 13 +- iOSClient/Login/NCLoginProvider.swift | 186 +++++++----------- .../en.lproj/Localizable.strings | 1 + 4 files changed, 79 insertions(+), 130 deletions(-) diff --git a/Brand/Intro/NCIntroViewController.swift b/Brand/Intro/NCIntroViewController.swift index c626a4c557..b9c430f13f 100644 --- a/Brand/Intro/NCIntroViewController.swift +++ b/Brand/Intro/NCIntroViewController.swift @@ -164,11 +164,10 @@ class NCIntroViewController: UIViewController, UICollectionViewDataSource, UICol } @IBAction func signupWithProvider(_ sender: Any) { - if let viewController = UIStoryboard(name: "NCLogin", bundle: nil).instantiateViewController(withIdentifier: "NCLoginProvider") as? NCLoginProvider { - viewController.controller = self.controller - viewController.initialURLString = NCBrandOptions.shared.linkloginPreferredProviders - self.navigationController?.pushViewController(viewController, animated: true) - } + let loginProviderVC = NCLoginProvider() + loginProviderVC.controller = self.controller + loginProviderVC.initialURLString = NCBrandOptions.shared.linkloginPreferredProviders + self.navigationController?.pushViewController(loginProviderVC, animated: true) } @IBAction func host(_ sender: Any) { diff --git a/iOSClient/Login/NCLogin.swift b/iOSClient/Login/NCLogin.swift index 6740efb7c4..a31c143b03 100644 --- a/iOSClient/Login/NCLogin.swift +++ b/iOSClient/Login/NCLogin.swift @@ -338,12 +338,13 @@ class NCLogin: UIViewController, UITextFieldDelegate, NCLoginQRCodeDelegate { // Login Flow V2 if error == .success, let token, let endpoint, let login { nkLog(debug: "Successfully received login flow information.") - let safariVC = NCLoginProvider() - safariVC.initialURLString = login - safariVC.uiColor = textColor - safariVC.delegate = self - safariVC.startPolling(loginFlowV2Token: token, loginFlowV2Endpoint: endpoint, loginFlowV2Login: login) - navigationController?.pushViewController(safariVC, animated: true) + let loginProviderVC = NCLoginProvider() + loginProviderVC.initialURLString = login + loginProviderVC.uiColor = textColor + loginProviderVC.delegate = self + loginProviderVC.controller = self.controller + loginProviderVC.startPolling(loginFlowV2Token: token, loginFlowV2Endpoint: endpoint, loginFlowV2Login: login) + navigationController?.pushViewController(loginProviderVC, animated: true) } } case .failure(let error): diff --git a/iOSClient/Login/NCLoginProvider.swift b/iOSClient/Login/NCLoginProvider.swift index c427fd3205..fc0ce9d890 100644 --- a/iOSClient/Login/NCLoginProvider.swift +++ b/iOSClient/Login/NCLoginProvider.swift @@ -3,8 +3,8 @@ // SPDX-FileCopyrightText: 2025 Milen Pivchev // SPDX-License-Identifier: GPL-3.0-or-later +import AuthenticationServices import UIKit -@preconcurrency import WebKit import NextcloudKit protocol NCLoginProviderDelegate: AnyObject { @@ -15,16 +15,18 @@ protocol NCLoginProviderDelegate: AnyObject { } /// -/// View which presents the web view to login at a Nextcloud instance. +/// View controller that handles login authentication using ASWebAuthenticationSession. /// -class NCLoginProvider: UIViewController { - var webView: WKWebView! +class NCLoginProvider: UIViewController, ASWebAuthenticationPresentationContextProviding { var titleView: String = "" var initialURLString = "" var uiColor: UIColor = .white weak var delegate: NCLoginProviderDelegate? var controller: NCMainTabBarController? + /// The active authentication session. + private var authSession: ASWebAuthenticationSession? + /// /// A polling loop active in the background to check for the current status of the login flow. /// @@ -35,22 +37,8 @@ class NCLoginProvider: UIViewController { override func viewDidLoad() { super.viewDidLoad() nkLog(debug: "Login provider view did load.") - let configuration = WKWebViewConfiguration() - configuration.websiteDataStore = WKWebsiteDataStore.nonPersistent() - - let webView = WKWebView(frame: CGRect.zero, configuration: configuration) - webView.customUserAgent = userAgent - webView.isInspectable = true - webView.navigationDelegate = self - view.addSubview(webView) - webView.translatesAutoresizingMaskIntoConstraints = false - webView.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 0).isActive = true - webView.rightAnchor.constraint(equalTo: view.rightAnchor, constant: 0).isActive = true - webView.topAnchor.constraint(equalTo: view.topAnchor, constant: 0).isActive = true - webView.bottomAnchor.constraint(equalTo: view.bottomAnchor, constant: 0).isActive = true - - self.webView = webView + view.backgroundColor = NCBrandColor.shared.customer let navigationItemBack = UIBarButtonItem(image: UIImage(systemName: "arrow.left"), style: .plain, target: self, action: #selector(goBack(_:))) navigationItemBack.tintColor = uiColor @@ -76,15 +64,16 @@ class NCLoginProvider: UIViewController { } } - loadWebPage(url: url) self.title = titleView + startAuthentication(url: url) } override func viewDidDisappear(_ animated: Bool) { super.viewDidDisappear(animated) nkLog(debug: "Login provider view did disappear.") - NCActivityIndicator.shared.stop() + authSession?.cancel() + authSession = nil guard pollingTask != nil else { return @@ -95,20 +84,16 @@ class NCLoginProvider: UIViewController { pollingTask = nil } - // MARK: - Navigation - - private func loadWebPage(url: URL) { - let language = NSLocale.preferredLanguages[0] as String - var request = URLRequest(url: url) - - request.addValue("true", forHTTPHeaderField: "OCS-APIRequest") - request.addValue(language, forHTTPHeaderField: "Accept-Language") + // MARK: - ASWebAuthenticationPresentationContextProviding - webView.load(request) + func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor { + return view.window ?? ASPresentationAnchor() } + // MARK: - Navigation + /// - /// Dismiss the login web view from the hierarchy. + /// Dismiss the login view from the hierarchy. /// @objc func goBack(_ sender: Any?) { delegate?.onBack() @@ -120,10 +105,52 @@ class NCLoginProvider: UIViewController { } } + // MARK: - Authentication + + /// + /// Start the authentication flow using ASWebAuthenticationSession. + /// + private func startAuthentication(url: URL) { + // Use nil callback scheme - we rely on polling to detect successful login. + authSession = ASWebAuthenticationSession(url: url, callbackURLScheme: nil) { [weak self] _, error in + guard let self else { return } + + if let error = error { + let nsError = error as NSError + + if let asError = error as? ASWebAuthenticationSessionError, asError.code == .canceledLogin { + // Only treat as user cancellation if polling hasn't succeeded yet + if self.pollingTask != nil { + Task { @MainActor in + self.goBack(nil) + } + } + } else { + Task { @MainActor in + await showErrorBanner(controller: self.controller, text: "_login_error_", errorCode: nsError.code) + self.goBack(nil) + } + } + return + } + } + + authSession?.presentationContextProvider = self + // Use non-ephemeral session to access system-trusted certificates + authSession?.prefersEphemeralWebBrowserSession = false + + if authSession?.start() != true { + Task { @MainActor in + await showErrorBanner(controller: self.controller, text: "_login_error_", errorCode: 0) + self.goBack(nil) + } + } + } + // MARK: - Polling /// - /// Start checking the status of the login flow in the background periodally. + /// Start checking the status of the login flow in the background periodically. /// func startPolling(loginFlowV2Token: String, loginFlowV2Endpoint: String, loginFlowV2Login: String) { nkLog(start: "Starting polling at \(loginFlowV2Endpoint) with token \(loginFlowV2Token)") @@ -136,7 +163,7 @@ class NCLoginProvider: UIViewController { /// private func poll(token: String, endpoint: String, options: NKRequestOptions) async -> (urlBase: String, loginName: String, appPassword: String)? { await withCheckedContinuation { continuation in - NextcloudKit.shared.getLoginFlowV2Poll(token: token, endpoint: endpoint, options: options) {server, loginName, appPassword, _, error in + NextcloudKit.shared.getLoginFlowV2Poll(token: token, endpoint: endpoint, options: options) { server, loginName, appPassword, _, error in guard error == .success else { nkLog(error: "Login poll result for token \"\(token)\" is not successful!") @@ -174,6 +201,10 @@ class NCLoginProvider: UIViewController { private func handleGrant(urlBase: String, loginName: String, appPassword: String) async { nkLog(debug: "Handling login grant values for \(loginName) on \(urlBase)") + // Cancel the auth session since login was successful + authSession?.cancel() + authSession = nil + if controller == nil { nkLog(debug: "View controller is still undefined, will resolve root view controller of first window.") controller = UIApplication.shared.mainAppWindow?.rootViewController as? NCMainTabBarController @@ -202,95 +233,12 @@ class NCLoginProvider: UIViewController { return } + // Clear the polling task before handling grant to prevent cancellation callback + self.pollingTask = nil + await handleGrant(urlBase: grantValues.urlBase, loginName: grantValues.loginName, appPassword: grantValues.appPassword) nkLog(debug: "Polling task completed.") } } } -// MARK: - WKNavigationDelegate - -extension NCLoginProvider: WKNavigationDelegate { - func webView(_ webView: WKWebView, didReceiveServerRedirectForProvisionalNavigation navigation: WKNavigation!) { - nkLog(debug: "Web view did receive server redirect for provisional navigation.") - - guard let currentWebViewURL = webView.url else { - nkLog(error: "Web view does not have a URL after receiving a server redirect for provisional navigation!") - return - } - - let currentWebViewURLString: String = currentWebViewURL.absoluteString.lowercased() - - // Prevent HTTP redirects. - if initialURLString.lowercased().hasPrefix("https://") && currentWebViewURLString.hasPrefix("http://") { - nkLog(error: "Web view redirect degrades session from HTTPS to HTTP and must be cancelled!") - - let alertController = UIAlertController(title: NSLocalizedString("_error_", comment: ""), message: NSLocalizedString("_prevent_http_redirection_", comment: ""), preferredStyle: .alert) - - alertController.addAction(UIAlertAction(title: NSLocalizedString("_ok_", comment: ""), style: .default, handler: { _ in - _ = self.navigationController?.popViewController(animated: true) - })) - - self.present(alertController, animated: true) - - return - } - - // Login via provider. - if currentWebViewURLString.hasPrefix(NCBrandOptions.shared.webLoginAutenticationProtocol) && currentWebViewURLString.contains("login") { - nkLog(debug: "Web view redirect to provider login URL detected.") - - var server: String = "" - var user: String = "" - var password: String = "" - let keyValue = currentWebViewURL.path.components(separatedBy: "&") - - for value in keyValue { - if value.contains("server:") { server = value } - if value.contains("user:") { user = value } - if value.contains("password:") { password = value } - } - - if !server.isEmpty, !user.isEmpty, !password.isEmpty { - let server: String = server.replacingOccurrences(of: "/server:", with: "") - let username: String = user.replacingOccurrences(of: "user:", with: "").replacingOccurrences(of: "+", with: " ") - let password: String = password.replacingOccurrences(of: "password:", with: "") - - if self.controller == nil { - self.controller = UIApplication.shared.mainAppWindow?.rootViewController as? NCMainTabBarController - } - - Task { @MainActor in - await NCAccount().createAccount(viewController: self, urlBase: server, user: username, password: password, controller: controller) - } - } - } - } - - func webView(_ webView: WKWebView, didReceive challenge: URLAuthenticationChallenge, completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) { - nkLog(debug: "Web view did receive authentication challenge.") - - DispatchQueue.global().async { - if let serverTrust = challenge.protectionSpace.serverTrust { - completionHandler(Foundation.URLSession.AuthChallengeDisposition.useCredential, URLCredential(trust: serverTrust)) - } else { - completionHandler(URLSession.AuthChallengeDisposition.useCredential, nil) - } - } - } - - func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) { - nkLog(debug: "Web view will allow navigation to \(navigationAction.request.url?.absoluteString ?? "nil")") - decisionHandler(.allow) - } - - func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation!) { - nkLog(debug: "Web view did start provisional navigation.") - NCActivityIndicator.shared.startActivity(backgroundView: self.view, style: .medium, blurEffect: false) - } - - func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { - nkLog(debug: "Web view did finish navigation to \(webView.url?.absoluteString ?? "nil")") - NCActivityIndicator.shared.stop() - } -} diff --git a/iOSClient/Supporting Files/en.lproj/Localizable.strings b/iOSClient/Supporting Files/en.lproj/Localizable.strings index d83b987a48..6813746ec4 100644 --- a/iOSClient/Supporting Files/en.lproj/Localizable.strings +++ b/iOSClient/Supporting Files/en.lproj/Localizable.strings @@ -291,6 +291,7 @@ "_connection_error_" = "Connection error"; "_login_url_" = "Server address https:// …"; "_login_url_error_" = "URL error, please verify your server URL"; +"_login_error_" = "Could not log in. Please try again later."; "_favorites_" = "Favorites"; "_favorite_short_" = "Favorite"; "_tutorial_favorite_view_" = "Files and folders you mark as favorites will show up here"; From 1fc14c58e666d76e33fe76ad020b00e0074e3f6c Mon Sep 17 00:00:00 2001 From: Milen Pivchev Date: Fri, 20 Feb 2026 16:21:29 +0100 Subject: [PATCH 2/5] WIP Signed-off-by: Milen Pivchev --- iOSClient/Login/NCLoginProvider.swift | 175 ++++++++++++++++++++++++-- 1 file changed, 165 insertions(+), 10 deletions(-) diff --git a/iOSClient/Login/NCLoginProvider.swift b/iOSClient/Login/NCLoginProvider.swift index fc0ce9d890..87621840f9 100644 --- a/iOSClient/Login/NCLoginProvider.swift +++ b/iOSClient/Login/NCLoginProvider.swift @@ -5,6 +5,7 @@ import AuthenticationServices import UIKit +@preconcurrency import WebKit import NextcloudKit protocol NCLoginProviderDelegate: AnyObject { @@ -15,7 +16,8 @@ protocol NCLoginProviderDelegate: AnyObject { } /// -/// View controller that handles login authentication using ASWebAuthenticationSession. +/// Handles login authentication. +/// Uses ASWebAuthenticationSession for passkey support, with WKWebView fallback for certificate handling. /// class NCLoginProvider: UIViewController, ASWebAuthenticationPresentationContextProviding { var titleView: String = "" @@ -27,6 +29,12 @@ class NCLoginProvider: UIViewController, ASWebAuthenticationPresentationContextP /// The active authentication session. private var authSession: ASWebAuthenticationSession? + /// Fallback web view for certificate handling. + private var webView: WKWebView? + + /// Whether we're using the WKWebView fallback. + private var isUsingWebViewFallback = false + /// /// A polling loop active in the background to check for the current status of the login flow. /// @@ -75,6 +83,8 @@ class NCLoginProvider: UIViewController, ASWebAuthenticationPresentationContextP authSession?.cancel() authSession = nil + NCActivityIndicator.shared.stop() + guard pollingTask != nil else { return } @@ -109,10 +119,13 @@ class NCLoginProvider: UIViewController, ASWebAuthenticationPresentationContextP /// /// Start the authentication flow using ASWebAuthenticationSession. + /// Falls back to WKWebView if authentication fails (e.g., for importing mTLS cert). /// private func startAuthentication(url: URL) { - // Use nil callback scheme - we rely on polling to detect successful login. - authSession = ASWebAuthenticationSession(url: url, callbackURLScheme: nil) { [weak self] _, error in + // Use custom URL scheme to handle login callbacks (e.g., nc://login/...) + let callbackScheme = NCBrandOptions.shared.webLoginAutenticationProtocol.replacingOccurrences(of: "://", with: "") + + authSession = ASWebAuthenticationSession(url: url, callbackURLScheme: callbackScheme) { [weak self] callbackURL, error in guard let self else { return } if let error = error { @@ -126,23 +139,115 @@ class NCLoginProvider: UIViewController, ASWebAuthenticationPresentationContextP } } } else { + // Fall back to WKWebView for other errors (e.g., certificate issues) + nkLog(debug: "ASWebAuthenticationSession failed with error: \(nsError.localizedDescription). Falling back to WKWebView.") Task { @MainActor in - await showErrorBanner(controller: self.controller, text: "_login_error_", errorCode: nsError.code) - self.goBack(nil) + self.fallbackToWebView(url: url) } } return } + + // Handle login callback URL (e.g., nc://login/server:...&user:...&password:...) + if let callbackURL { + self.handleLoginCallback(url: callbackURL) + } } authSession?.presentationContextProvider = self - // Use non-ephemeral session to access system-trusted certificates - authSession?.prefersEphemeralWebBrowserSession = false + authSession?.prefersEphemeralWebBrowserSession = true if authSession?.start() != true { + // Fall back to WKWebView if ASWebAuthenticationSession fails to start + nkLog(debug: "ASWebAuthenticationSession failed to start. Falling back to WKWebView.") + fallbackToWebView(url: url) + } + } + + // MARK: - WKWebView Fallback + + /// + /// Set up and display WKWebView as a fallback for certificate handling. + /// + private func fallbackToWebView(url: URL) { + guard !isUsingWebViewFallback else { return } + isUsingWebViewFallback = true + + authSession?.cancel() + authSession = nil + + nkLog(debug: "Setting up WKWebView fallback for URL: \(url.absoluteString)") + + let configuration = WKWebViewConfiguration() + configuration.websiteDataStore = WKWebsiteDataStore.nonPersistent() + + let webView = WKWebView(frame: .zero, configuration: configuration) + webView.customUserAgent = userAgent + webView.navigationDelegate = self + view.addSubview(webView) + + webView.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + webView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + webView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + webView.topAnchor.constraint(equalTo: view.topAnchor), + webView.bottomAnchor.constraint(equalTo: view.bottomAnchor) + ]) + + self.webView = webView + loadWebPage(url: url) + } + + /// + /// Load a web page in the fallback WKWebView. + /// + private func loadWebPage(url: URL) { + let language = NSLocale.preferredLanguages[0] as String + var request = URLRequest(url: url) + + request.addValue("true", forHTTPHeaderField: "OCS-APIRequest") + request.addValue(language, forHTTPHeaderField: "Accept-Language") + + webView?.load(request) + } + + /// + /// Handle the login callback URL from the authentication session. + /// + private func handleLoginCallback(url: URL) { + let urlString = url.absoluteString.lowercased() + + // Check if this is a login callback + guard urlString.hasPrefix(NCBrandOptions.shared.webLoginAutenticationProtocol) && urlString.contains("login") else { + return + } + + var server: String = "" + var user: String = "" + var password: String = "" + let keyValue = url.path.components(separatedBy: "&") + + for value in keyValue { + if value.contains("server:") { server = value } + if value.contains("user:") { user = value } + if value.contains("password:") { password = value } + } + + if !server.isEmpty, !user.isEmpty, !password.isEmpty { + let server = server.replacingOccurrences(of: "/server:", with: "") + let username = user.replacingOccurrences(of: "user:", with: "").replacingOccurrences(of: "+", with: " ") + let password = password.replacingOccurrences(of: "password:", with: "") + + // Stop polling since we got credentials from callback + pollingTask?.cancel() + pollingTask = nil + + if self.controller == nil { + self.controller = UIApplication.shared.mainAppWindow?.rootViewController as? NCMainTabBarController + } + Task { @MainActor in - await showErrorBanner(controller: self.controller, text: "_login_error_", errorCode: 0) - self.goBack(nil) + await NCAccount().createAccount(viewController: self, urlBase: server, user: username, password: password, controller: self.controller) } } } @@ -166,7 +271,6 @@ class NCLoginProvider: UIViewController, ASWebAuthenticationPresentationContextP NextcloudKit.shared.getLoginFlowV2Poll(token: token, endpoint: endpoint, options: options) { server, loginName, appPassword, _, error in guard error == .success else { - nkLog(error: "Login poll result for token \"\(token)\" is not successful!") continuation.resume(returning: nil) return } @@ -242,3 +346,54 @@ class NCLoginProvider: UIViewController, ASWebAuthenticationPresentationContextP } } +// MARK: - WKNavigationDelegate + +extension NCLoginProvider: WKNavigationDelegate { + func webView(_ webView: WKWebView, didReceiveServerRedirectForProvisionalNavigation navigation: WKNavigation!) { + guard let currentWebViewURL = webView.url else { + return + } + + let currentWebViewURLString: String = currentWebViewURL.absoluteString.lowercased() + + // Prevent HTTP redirects. + if initialURLString.lowercased().hasPrefix("https://") && currentWebViewURLString.hasPrefix("http://") { + let alertController = UIAlertController(title: NSLocalizedString("_error_", comment: ""), message: NSLocalizedString("_prevent_http_redirection_", comment: ""), preferredStyle: .alert) + + alertController.addAction(UIAlertAction(title: NSLocalizedString("_ok_", comment: ""), style: .default, handler: { _ in + _ = self.navigationController?.popViewController(animated: true) + })) + + self.present(alertController, animated: true) + return + } + + // Login via provider. + if currentWebViewURLString.hasPrefix(NCBrandOptions.shared.webLoginAutenticationProtocol) && currentWebViewURLString.contains("login") { + handleLoginCallback(url: currentWebViewURL) + } + } + + func webView(_ webView: WKWebView, didReceive challenge: URLAuthenticationChallenge, completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) { + DispatchQueue.global().async { + if let serverTrust = challenge.protectionSpace.serverTrust { + completionHandler(Foundation.URLSession.AuthChallengeDisposition.useCredential, URLCredential(trust: serverTrust)) + } else { + completionHandler(URLSession.AuthChallengeDisposition.useCredential, nil) + } + } + } + + func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) { + decisionHandler(.allow) + } + + func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation!) { + NCActivityIndicator.shared.startActivity(backgroundView: self.view, style: .medium, blurEffect: false) + } + + func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { + NCActivityIndicator.shared.stop() + } +} + From 3008bc0a28fa8209816f0e3d98441cd16f47353c Mon Sep 17 00:00:00 2001 From: Milen Pivchev Date: Fri, 20 Feb 2026 16:35:40 +0100 Subject: [PATCH 3/5] Use ASWebAuthenticationSession Signed-off-by: Milen Pivchev --- Brand/Intro/NCIntroViewController.swift | 11 +- iOSClient/Login/NCLogin.swift | 18 +- iOSClient/Login/NCLoginProvider.swift | 286 ++++++++++++------------ 3 files changed, 160 insertions(+), 155 deletions(-) diff --git a/Brand/Intro/NCIntroViewController.swift b/Brand/Intro/NCIntroViewController.swift index b9c430f13f..d340729b8b 100644 --- a/Brand/Intro/NCIntroViewController.swift +++ b/Brand/Intro/NCIntroViewController.swift @@ -22,6 +22,7 @@ class NCIntroViewController: UIViewController, UICollectionViewDataSource, UICol private var timer: Timer? private var textColor: UIColor = .white private var textColorOpponent: UIColor = .black + private var activeLoginProvider: NCLoginProvider? // MARK: - View Life Cycle @@ -164,10 +165,12 @@ class NCIntroViewController: UIViewController, UICollectionViewDataSource, UICol } @IBAction func signupWithProvider(_ sender: Any) { - let loginProviderVC = NCLoginProvider() - loginProviderVC.controller = self.controller - loginProviderVC.initialURLString = NCBrandOptions.shared.linkloginPreferredProviders - self.navigationController?.pushViewController(loginProviderVC, animated: true) + let loginProvider = NCLoginProvider() + loginProvider.controller = self.controller + loginProvider.initialURLString = NCBrandOptions.shared.linkloginPreferredProviders + loginProvider.presentingViewController = self + loginProvider.startAuthentication() + self.activeLoginProvider = loginProvider } @IBAction func host(_ sender: Any) { diff --git a/iOSClient/Login/NCLogin.swift b/iOSClient/Login/NCLogin.swift index a31c143b03..892d88885d 100644 --- a/iOSClient/Login/NCLogin.swift +++ b/iOSClient/Login/NCLogin.swift @@ -44,6 +44,7 @@ class NCLogin: UIViewController, UITextFieldDelegate, NCLoginQRCodeDelegate { private var p12Data: Data? private var p12Password: String? private var QRCodeCheck: Bool = false + private var activeLoginProvider: NCLoginProvider? // MARK: - View Life Cycle @@ -338,13 +339,14 @@ class NCLogin: UIViewController, UITextFieldDelegate, NCLoginQRCodeDelegate { // Login Flow V2 if error == .success, let token, let endpoint, let login { nkLog(debug: "Successfully received login flow information.") - let loginProviderVC = NCLoginProvider() - loginProviderVC.initialURLString = login - loginProviderVC.uiColor = textColor - loginProviderVC.delegate = self - loginProviderVC.controller = self.controller - loginProviderVC.startPolling(loginFlowV2Token: token, loginFlowV2Endpoint: endpoint, loginFlowV2Login: login) - navigationController?.pushViewController(loginProviderVC, animated: true) + let loginProvider = NCLoginProvider() + loginProvider.initialURLString = login + loginProvider.delegate = self + loginProvider.controller = self.controller + loginProvider.presentingViewController = self + loginProvider.startPolling(loginFlowV2Token: token, loginFlowV2Endpoint: endpoint, loginFlowV2Login: login) + loginProvider.startAuthentication() + self.activeLoginProvider = loginProvider } } case .failure(let error): @@ -507,5 +509,7 @@ extension NCLogin: NCLoginProviderDelegate { func onBack() { loginButton.isEnabled = true loginButton.hideSpinnerAndShowButton() + activeLoginProvider?.cancel() + activeLoginProvider = nil } } diff --git a/iOSClient/Login/NCLoginProvider.swift b/iOSClient/Login/NCLoginProvider.swift index 87621840f9..83ae2ae618 100644 --- a/iOSClient/Login/NCLoginProvider.swift +++ b/iOSClient/Login/NCLoginProvider.swift @@ -10,53 +10,46 @@ import NextcloudKit protocol NCLoginProviderDelegate: AnyObject { /// - /// Called when the back button is tapped in the login provider view. + /// Called when the authentication is cancelled or fails. /// func onBack() } /// -/// Handles login authentication. -/// Uses ASWebAuthenticationSession for passkey support, with WKWebView fallback for certificate handling. +/// Handles login authentication using ASWebAuthenticationSession with WKWebView fallback for mTLS. /// -class NCLoginProvider: UIViewController, ASWebAuthenticationPresentationContextProviding { - var titleView: String = "" +class NCLoginProvider: NSObject, ASWebAuthenticationPresentationContextProviding { var initialURLString = "" - var uiColor: UIColor = .white weak var delegate: NCLoginProviderDelegate? var controller: NCMainTabBarController? + /// The presenting view controller for the authentication session. + weak var presentingViewController: UIViewController? + /// The active authentication session. private var authSession: ASWebAuthenticationSession? - /// Fallback web view for certificate handling. - private var webView: WKWebView? - - /// Whether we're using the WKWebView fallback. - private var isUsingWebViewFallback = false + /// Fallback web view controller for mTLS/certificate handling. + private var webViewFallbackVC: NCLoginProviderWebViewFallback? /// /// A polling loop active in the background to check for the current status of the login flow. /// var pollingTask: Task? - // MARK: - View Life Cycle - - override func viewDidLoad() { - super.viewDidLoad() - nkLog(debug: "Login provider view did load.") - - view.backgroundColor = NCBrandColor.shared.customer + // MARK: - ASWebAuthenticationPresentationContextProviding - let navigationItemBack = UIBarButtonItem(image: UIImage(systemName: "arrow.left"), style: .plain, target: self, action: #selector(goBack(_:))) - navigationItemBack.tintColor = uiColor - navigationItem.leftBarButtonItem = navigationItemBack + func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor { + return presentingViewController?.view.window ?? UIApplication.shared.mainAppWindow ?? ASPresentationAnchor() } - override func viewDidAppear(_ animated: Bool) { - super.viewDidAppear(animated) - nkLog(debug: "Login provider appeared.") + // MARK: - Authentication + /// + /// Start the authentication flow using ASWebAuthenticationSession. + /// Falls back to WKWebView if authentication fails (e.g., certificate issues). + /// + func startAuthentication() { guard let url = URL(string: initialURLString) else { Task { await showErrorBanner(controller: self.controller, text: "_login_url_error_", errorCode: 0) @@ -64,83 +57,22 @@ class NCLoginProvider: UIViewController, ASWebAuthenticationPresentationContextP return } - if let host = url.host { - titleView = host - - if let activeTableAccount = NCManageDatabase.shared.getActiveTableAccount(), NCPreferences().getPassword(account: activeTableAccount.account).isEmpty { - titleView = NSLocalizedString("_user_", comment: "") + " " + activeTableAccount.userId + " " + NSLocalizedString("_in_", comment: "") + " " + host - } - } - - self.title = titleView - startAuthentication(url: url) - } - - override func viewDidDisappear(_ animated: Bool) { - super.viewDidDisappear(animated) - nkLog(debug: "Login provider view did disappear.") - - authSession?.cancel() - authSession = nil - - NCActivityIndicator.shared.stop() - - guard pollingTask != nil else { - return - } - - nkLog(debug: "Cancelling existing polling task because view did disappear...") - pollingTask?.cancel() - pollingTask = nil - } - - // MARK: - ASWebAuthenticationPresentationContextProviding - - func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor { - return view.window ?? ASPresentationAnchor() - } - - // MARK: - Navigation - - /// - /// Dismiss the login view from the hierarchy. - /// - @objc func goBack(_ sender: Any?) { - delegate?.onBack() - - if isModal { - dismiss(animated: true) - } else { - navigationController?.popViewController(animated: true) - } - } - - // MARK: - Authentication - - /// - /// Start the authentication flow using ASWebAuthenticationSession. - /// Falls back to WKWebView if authentication fails (e.g., for importing mTLS cert). - /// - private func startAuthentication(url: URL) { - // Use custom URL scheme to handle login callbacks (e.g., nc://login/...) + // Use the app's custom URL scheme to handle login callbacks (e.g., nc://login/...) let callbackScheme = NCBrandOptions.shared.webLoginAutenticationProtocol.replacingOccurrences(of: "://", with: "") authSession = ASWebAuthenticationSession(url: url, callbackURLScheme: callbackScheme) { [weak self] callbackURL, error in guard let self else { return } if let error = error { - let nsError = error as NSError - if let asError = error as? ASWebAuthenticationSessionError, asError.code == .canceledLogin { // Only treat as user cancellation if polling hasn't succeeded yet if self.pollingTask != nil { Task { @MainActor in - self.goBack(nil) + self.delegate?.onBack() } } } else { // Fall back to WKWebView for other errors (e.g., certificate issues) - nkLog(debug: "ASWebAuthenticationSession failed with error: \(nsError.localizedDescription). Falling back to WKWebView.") Task { @MainActor in self.fallbackToWebView(url: url) } @@ -159,62 +91,52 @@ class NCLoginProvider: UIViewController, ASWebAuthenticationPresentationContextP if authSession?.start() != true { // Fall back to WKWebView if ASWebAuthenticationSession fails to start - nkLog(debug: "ASWebAuthenticationSession failed to start. Falling back to WKWebView.") fallbackToWebView(url: url) } } - // MARK: - WKWebView Fallback - /// - /// Set up and display WKWebView as a fallback for certificate handling. + /// Cancel the authentication session and clean up. /// - private func fallbackToWebView(url: URL) { - guard !isUsingWebViewFallback else { return } - isUsingWebViewFallback = true - + func cancel() { authSession?.cancel() authSession = nil - nkLog(debug: "Setting up WKWebView fallback for URL: \(url.absoluteString)") + webViewFallbackVC?.dismiss(animated: true) + webViewFallbackVC = nil - let configuration = WKWebViewConfiguration() - configuration.websiteDataStore = WKWebsiteDataStore.nonPersistent() - - let webView = WKWebView(frame: .zero, configuration: configuration) - webView.customUserAgent = userAgent - webView.navigationDelegate = self - view.addSubview(webView) - - webView.translatesAutoresizingMaskIntoConstraints = false - NSLayoutConstraint.activate([ - webView.leadingAnchor.constraint(equalTo: view.leadingAnchor), - webView.trailingAnchor.constraint(equalTo: view.trailingAnchor), - webView.topAnchor.constraint(equalTo: view.topAnchor), - webView.bottomAnchor.constraint(equalTo: view.bottomAnchor) - ]) - - self.webView = webView - loadWebPage(url: url) + pollingTask?.cancel() + pollingTask = nil } + // MARK: - WKWebView Fallback + /// - /// Load a web page in the fallback WKWebView. + /// Present WKWebView as a fallback for mTLS/certificate handling. /// - private func loadWebPage(url: URL) { - let language = NSLocale.preferredLanguages[0] as String - var request = URLRequest(url: url) + private func fallbackToWebView(url: URL) { + authSession?.cancel() + authSession = nil - request.addValue("true", forHTTPHeaderField: "OCS-APIRequest") - request.addValue(language, forHTTPHeaderField: "Accept-Language") + guard let presentingVC = presentingViewController else { return } - webView?.load(request) + let fallbackVC = NCLoginProviderWebViewFallback() + fallbackVC.initialURL = url + fallbackVC.initialURLString = initialURLString + fallbackVC.controller = controller + fallbackVC.loginProvider = self + + let navController = UINavigationController(rootViewController: fallbackVC) + navController.modalPresentationStyle = .fullScreen + + presentingVC.present(navController, animated: true) + self.webViewFallbackVC = fallbackVC } /// /// Handle the login callback URL from the authentication session. /// - private func handleLoginCallback(url: URL) { + func handleLoginCallback(url: URL) { let urlString = url.absoluteString.lowercased() // Check if this is a login callback @@ -242,12 +164,17 @@ class NCLoginProvider: UIViewController, ASWebAuthenticationPresentationContextP pollingTask?.cancel() pollingTask = nil + // Dismiss fallback web view if present + webViewFallbackVC?.dismiss(animated: true) + webViewFallbackVC = nil + if self.controller == nil { self.controller = UIApplication.shared.mainAppWindow?.rootViewController as? NCMainTabBarController } Task { @MainActor in - await NCAccount().createAccount(viewController: self, urlBase: server, user: username, password: password, controller: self.controller) + guard let viewController = self.presentingViewController else { return } + await NCAccount().createAccount(viewController: viewController, urlBase: server, user: username, password: password, controller: self.controller) } } } @@ -302,19 +229,28 @@ class NCLoginProvider: UIViewController, ASWebAuthenticationPresentationContextP /// /// Handle the values acquired by polling successfully. /// - private func handleGrant(urlBase: String, loginName: String, appPassword: String) async { + func handleGrant(urlBase: String, loginName: String, appPassword: String) async { nkLog(debug: "Handling login grant values for \(loginName) on \(urlBase)") // Cancel the auth session since login was successful authSession?.cancel() authSession = nil + // Dismiss fallback web view if present + webViewFallbackVC?.dismiss(animated: true) + webViewFallbackVC = nil + if controller == nil { nkLog(debug: "View controller is still undefined, will resolve root view controller of first window.") controller = UIApplication.shared.mainAppWindow?.rootViewController as? NCMainTabBarController } - await NCAccount().createAccount(viewController: self, urlBase: urlBase, user: loginName, password: appPassword, controller: controller) + guard let viewController = presentingViewController else { + nkLog(error: "No presenting view controller available for account creation.") + return + } + + await NCAccount().createAccount(viewController: viewController, urlBase: urlBase, user: loginName, password: appPassword, controller: controller) nkLog(debug: "Account creation for \(loginName) on \(urlBase) completed based on login grant values.") } @@ -346,40 +282,103 @@ class NCLoginProvider: UIViewController, ASWebAuthenticationPresentationContextP } } -// MARK: - WKNavigationDelegate +// MARK: - WKWebView Fallback View Controller -extension NCLoginProvider: WKNavigationDelegate { - func webView(_ webView: WKWebView, didReceiveServerRedirectForProvisionalNavigation navigation: WKNavigation!) { - guard let currentWebViewURL = webView.url else { - return +/// +/// Fallback view controller using WKWebView for mTLS/certificate handling. +/// +class NCLoginProviderWebViewFallback: UIViewController, WKNavigationDelegate { + var initialURL: URL? + var initialURLString = "" + var controller: NCMainTabBarController? + weak var loginProvider: NCLoginProvider? + + private var webView: WKWebView! + + override func viewDidLoad() { + super.viewDidLoad() + + view.backgroundColor = NCBrandColor.shared.customer + + // Navigation bar + let closeButton = UIBarButtonItem(image: UIImage(systemName: "xmark"), style: .plain, target: self, action: #selector(closeTapped)) + closeButton.tintColor = .white + navigationItem.leftBarButtonItem = closeButton + + if let host = initialURL?.host { + title = host } - let currentWebViewURLString: String = currentWebViewURL.absoluteString.lowercased() + // Web view setup + let configuration = WKWebViewConfiguration() + configuration.websiteDataStore = WKWebsiteDataStore.nonPersistent() - // Prevent HTTP redirects. - if initialURLString.lowercased().hasPrefix("https://") && currentWebViewURLString.hasPrefix("http://") { - let alertController = UIAlertController(title: NSLocalizedString("_error_", comment: ""), message: NSLocalizedString("_prevent_http_redirection_", comment: ""), preferredStyle: .alert) + webView = WKWebView(frame: .zero, configuration: configuration) + webView.customUserAgent = userAgent + webView.navigationDelegate = self + view.addSubview(webView) - alertController.addAction(UIAlertAction(title: NSLocalizedString("_ok_", comment: ""), style: .default, handler: { _ in - _ = self.navigationController?.popViewController(animated: true) - })) + webView.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + webView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + webView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + webView.topAnchor.constraint(equalTo: view.topAnchor), + webView.bottomAnchor.constraint(equalTo: view.bottomAnchor) + ]) - self.present(alertController, animated: true) + if let url = initialURL { + loadWebPage(url: url) + } + } + + @objc private func closeTapped() { + loginProvider?.delegate?.onBack() + dismiss(animated: true) + } + + private func loadWebPage(url: URL) { + let language = NSLocale.preferredLanguages[0] as String + var request = URLRequest(url: url) + + request.addValue("true", forHTTPHeaderField: "OCS-APIRequest") + request.addValue(language, forHTTPHeaderField: "Accept-Language") + + webView.load(request) + } + + // MARK: - WKNavigationDelegate + + func webView(_ webView: WKWebView, didReceiveServerRedirectForProvisionalNavigation navigation: WKNavigation!) { + guard let currentWebViewURL = webView.url else { return } + + let currentWebViewURLString = currentWebViewURL.absoluteString.lowercased() + + // Prevent HTTP redirects + if initialURLString.lowercased().hasPrefix("https://") && currentWebViewURLString.hasPrefix("http://") { + let alertController = UIAlertController( + title: NSLocalizedString("_error_", comment: ""), + message: NSLocalizedString("_prevent_http_redirection_", comment: ""), + preferredStyle: .alert + ) + alertController.addAction(UIAlertAction(title: NSLocalizedString("_ok_", comment: ""), style: .default) { _ in + self.dismiss(animated: true) + }) + present(alertController, animated: true) return } - // Login via provider. + // Login via provider if currentWebViewURLString.hasPrefix(NCBrandOptions.shared.webLoginAutenticationProtocol) && currentWebViewURLString.contains("login") { - handleLoginCallback(url: currentWebViewURL) + loginProvider?.handleLoginCallback(url: currentWebViewURL) } } func webView(_ webView: WKWebView, didReceive challenge: URLAuthenticationChallenge, completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) { DispatchQueue.global().async { if let serverTrust = challenge.protectionSpace.serverTrust { - completionHandler(Foundation.URLSession.AuthChallengeDisposition.useCredential, URLCredential(trust: serverTrust)) + completionHandler(.useCredential, URLCredential(trust: serverTrust)) } else { - completionHandler(URLSession.AuthChallengeDisposition.useCredential, nil) + completionHandler(.useCredential, nil) } } } @@ -389,11 +388,10 @@ extension NCLoginProvider: WKNavigationDelegate { } func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation!) { - NCActivityIndicator.shared.startActivity(backgroundView: self.view, style: .medium, blurEffect: false) + NCActivityIndicator.shared.startActivity(backgroundView: view, style: .medium, blurEffect: false) } func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { NCActivityIndicator.shared.stop() } } - From 29eaf87435994444862912e2f9bf650498e988f4 Mon Sep 17 00:00:00 2001 From: Milen Pivchev Date: Fri, 20 Feb 2026 16:50:26 +0100 Subject: [PATCH 4/5] WIP Signed-off-by: Milen Pivchev --- iOSClient/Login/NCLoginProvider.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/iOSClient/Login/NCLoginProvider.swift b/iOSClient/Login/NCLoginProvider.swift index 83ae2ae618..c1858c540f 100644 --- a/iOSClient/Login/NCLoginProvider.swift +++ b/iOSClient/Login/NCLoginProvider.swift @@ -47,7 +47,7 @@ class NCLoginProvider: NSObject, ASWebAuthenticationPresentationContextProviding /// /// Start the authentication flow using ASWebAuthenticationSession. - /// Falls back to WKWebView if authentication fails (e.g., certificate issues). + /// Falls back to WKWebView if authentication fails (e.g., asked for mTLS cert etc). /// func startAuthentication() { guard let url = URL(string: initialURLString) else { @@ -227,7 +227,7 @@ class NCLoginProvider: NSObject, ASWebAuthenticationPresentationContextProviding } /// - /// Handle the values acquired by polling successfully. + /// Handle login when polling is successful and access is granted. /// func handleGrant(urlBase: String, loginName: String, appPassword: String) async { nkLog(debug: "Handling login grant values for \(loginName) on \(urlBase)") From f80b218e8eaa4caa38caab33b26f1dbc5059443e Mon Sep 17 00:00:00 2001 From: Milen Pivchev Date: Fri, 20 Feb 2026 16:57:04 +0100 Subject: [PATCH 5/5] WIP Signed-off-by: Milen Pivchev --- iOSClient/Login/NCLoginProvider.swift | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/iOSClient/Login/NCLoginProvider.swift b/iOSClient/Login/NCLoginProvider.swift index c1858c540f..8b6e495da3 100644 --- a/iOSClient/Login/NCLoginProvider.swift +++ b/iOSClient/Login/NCLoginProvider.swift @@ -57,7 +57,7 @@ class NCLoginProvider: NSObject, ASWebAuthenticationPresentationContextProviding return } - // Use the app's custom URL scheme to handle login callbacks (e.g., nc://login/...) + // Use custom URL scheme to handle login callbacks (e.g., nc://login/...) let callbackScheme = NCBrandOptions.shared.webLoginAutenticationProtocol.replacingOccurrences(of: "://", with: "") authSession = ASWebAuthenticationSession(url: url, callbackURLScheme: callbackScheme) { [weak self] callbackURL, error in @@ -229,7 +229,7 @@ class NCLoginProvider: NSObject, ASWebAuthenticationPresentationContextProviding /// /// Handle login when polling is successful and access is granted. /// - func handleGrant(urlBase: String, loginName: String, appPassword: String) async { + private func handleGrant(urlBase: String, loginName: String, appPassword: String) async { nkLog(debug: "Handling login grant values for \(loginName) on \(urlBase)") // Cancel the auth session since login was successful @@ -255,7 +255,7 @@ class NCLoginProvider: NSObject, ASWebAuthenticationPresentationContextProviding } /// - /// Set up the `Task` which frequently checks the server. + /// Sets up polling. /// private func createPollingTask(token: String, endpoint: String) -> Task { let options = NKRequestOptions(customUserAgent: userAgent) @@ -384,14 +384,17 @@ class NCLoginProviderWebViewFallback: UIViewController, WKNavigationDelegate { } func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) { + nkLog(debug: "Web view will allow navigation to \(navigationAction.request.url?.absoluteString ?? "nil")") decisionHandler(.allow) } func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation!) { + nkLog(debug: "Web view did start provisional navigation.") NCActivityIndicator.shared.startActivity(backgroundView: view, style: .medium, blurEffect: false) } func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { + nkLog(debug: "Web view did finish navigation to \(webView.url?.absoluteString ?? "nil")") NCActivityIndicator.shared.stop() } }