-
-
Notifications
You must be signed in to change notification settings - Fork 983
Improve login to use ASWebAuthenticationSession with integrated browser #3938
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Conversation
Signed-off-by: Fabian Aggeler <aggeler@ubique.ch>
…e login UI (webview, browser,...) Signed-off-by: Fabian Aggeler <aggeler@ubique.ch>
Signed-off-by: Fabian Aggeler <aggeler@ubique.ch>
Signed-off-by: Fabian Aggeler <aggeler@ubique.ch>
|
Hi @UBaggeler, thanks for the PR! We will review it soon. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Pull request overview
This PR modernizes the Nextcloud iOS app login flow by migrating from WKWebView to ASWebAuthenticationSession, addressing several critical user-facing issues with OAuth 2.0 flows, passkeys, deep links, and browser compatibility warnings.
Key changes:
- Replaces WKWebView-based login with ASWebAuthenticationSession to enable passkeys, deep links, and proper browser support
- Refactors login polling logic into a dedicated
NCLoginFlowPollerclass for better separation of concerns - Extracts callback URL parsing into a reusable
NCProviderLoginHandlerstruct
Reviewed changes
Copilot reviewed 7 out of 7 changed files in this pull request and generated 10 comments.
Show a summary per file
| File | Description |
|---|---|
| iOSClient/Login/NCWebViewLoginProvider.swift | Renamed from NCLoginProvider and removed polling logic, now focuses solely on WKWebView-based fallback login |
| iOSClient/Login/NCProviderLoginHandler.swift | New utility struct that centralizes callback URL parsing for provider login flows |
| iOSClient/Login/NCLoginFlowPoller.swift | New class encapsulating login flow V2 polling logic with proper task lifecycle management |
| iOSClient/Login/NCLogin.swift | Integrated ASWebAuthenticationSession for primary login flow with lifecycle management and error handling |
| iOSClient/Login/NCLogin.storyboard | Updated view controller reference to renamed NCWebViewLoginProvider class |
| Brand/Intro/NCIntroViewController.swift | Updated type reference to renamed NCWebViewLoginProvider class |
| Nextcloud.xcodeproj/project.pbxproj | Added new source files to project build configuration |
Comments suppressed due to low confidence (1)
iOSClient/Login/NCWebViewLoginProvider.swift:139
- The comparison on line 139 uses the lowercased
currentWebViewURLString, but passes the original (non-lowercased)currentWebViewURLto the handler. The handler then lowercases it again for comparison (line 10 of NCProviderLoginHandler). This works, but checking should be done consistently - either both use the original URL or both use lowercased versions to avoid confusion.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| if value.contains("server:") { server = value } | ||
| if value.contains("user:") { user = value } | ||
| if value.contains("password:") { password = value } |
Copilot
AI
Jan 6, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The parsing logic checks if values contain the prefixes "server:", "user:", "password:" but this could match false positives. For example, if a password contains the string "user:", it could be incorrectly assigned to the user variable. Consider using hasPrefix instead of contains to be more precise, or use a more robust URL component parsing approach.
| if value.contains("server:") { server = value } | |
| if value.contains("user:") { user = value } | |
| if value.contains("password:") { password = value } | |
| if value.hasPrefix("/server:") { server = value } | |
| if value.hasPrefix("user:") { user = value } | |
| if value.hasPrefix("password:") { password = value } |
| return nil | ||
| } | ||
|
|
||
| let serverClean = server.replacingOccurrences(of: "/server:", with: "") |
Copilot
AI
Jan 6, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The replacement logic on line 32 only replaces "/server:" but the check on line 23 uses "server:" without the leading slash. This inconsistency could lead to the prefix not being properly removed if the format doesn't match expectations. Ensure the format is consistent or handle both cases.
| let serverClean = server.replacingOccurrences(of: "/server:", with: "") | |
| let serverClean = server | |
| .replacingOccurrences(of: "/server:", with: "") | |
| .replacingOccurrences(of: "server:", with: "") |
| createAccount(urlBase: grant.urlBase, user: grant.loginName, password: grant.appPassword) | ||
| } | ||
|
|
||
| @MainActor |
Copilot
AI
Jan 6, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The function handleAuthenticationSessionCompletion is marked as @MainActor but is used as a completion handler for ASWebAuthenticationSession. According to Apple's documentation, completion handlers for ASWebAuthenticationSession are already called on the main thread, so the @MainActor annotation is redundant. While not harmful, it could be removed for clarity.
| @MainActor |
| Task { | ||
| await handleLoginGrant(NCLoginGrant(urlBase: providerGrant.urlBase, loginName: providerGrant.user, appPassword: providerGrant.password)) |
Copilot
AI
Jan 6, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
There's a potential race condition where the polling task could complete and call handleLoginGrant (line 384) at the same time as the authentication session completion handler calls it (line 442). Both paths could execute simultaneously if the user completes the login in the browser right when polling succeeds. Consider adding a flag or synchronization mechanism to ensure the account is only created once.
|
|
||
| struct NCLoginGrant { | ||
| let urlBase: String | ||
| let loginName: String |
Copilot
AI
Jan 6, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Variable naming inconsistency: the struct NCLoginGrant uses loginName (line 9) while the tuple returned by NCProviderLoginHandler.handle uses just user (line 9 of NCProviderLoginHandler.swift). When converting between them (line 442 of NCLogin.swift), it's converted to loginName. Consider using consistent naming across both types for better code clarity.
| } | ||
|
|
||
| private func startASWebAuthenticationSession(loginURL: URL, token: String, endpoint: String) { | ||
| let callbackScheme = URL(string: NCBrandOptions.shared.webLoginAutenticationProtocol)?.scheme ?? loginURL.scheme |
Copilot
AI
Jan 6, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The fallback to loginURL.scheme on line 389 may not be appropriate. If NCBrandOptions.shared.webLoginAutenticationProtocol cannot be parsed as a URL, using the loginURL scheme (likely "https") as the callback scheme will cause the authentication session to fail since the app won't receive callbacks on the https scheme. Consider logging this case or using a more explicit error handling approach.
| let callbackScheme = URL(string: NCBrandOptions.shared.webLoginAutenticationProtocol)?.scheme ?? loginURL.scheme | |
| guard | |
| let callbackURL = URL(string: NCBrandOptions.shared.webLoginAutenticationProtocol), | |
| let callbackScheme = callbackURL.scheme, | |
| !callbackScheme.isEmpty | |
| else { | |
| authenticationSession = nil | |
| loginFlowPoller.cancel() | |
| let error = NKError(errorCode: NCGlobal.shared.errorInternalError, errorDescription: "_error_something_wrong_") | |
| NCContentPresenter().showError(error: error, priority: .max) | |
| return | |
| } |
| let callbackURLString = callbackURL.absoluteString.lowercased() | ||
| let protocolPrefix = NCBrandOptions.shared.webLoginAutenticationProtocol.lowercased() | ||
|
|
||
| guard callbackURLString.hasPrefix(protocolPrefix), callbackURLString.contains("login") else { | ||
| return nil | ||
| } |
Copilot
AI
Jan 6, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The callback URL's absolute string is converted to lowercase for comparison, but the original URL (not lowercased) is used for parsing in line 20. This inconsistency could lead to issues if the parsing logic on line 20 expects case sensitivity. Consider using the original URL consistently, or only lowercase the protocol prefix for the prefix check.
| let callbackURLString = callbackURL.absoluteString.lowercased() | |
| let protocolPrefix = NCBrandOptions.shared.webLoginAutenticationProtocol.lowercased() | |
| guard callbackURLString.hasPrefix(protocolPrefix), callbackURLString.contains("login") else { | |
| return nil | |
| } | |
| let callbackURLString = callbackURL.absoluteString | |
| let protocolPrefix = NCBrandOptions.shared.webLoginAutenticationProtocol | |
| guard callbackURLString.range(of: protocolPrefix, options: [.caseInsensitive, .anchored]) != nil, | |
| callbackURLString.range(of: "login", options: .caseInsensitive) != nil else { | |
| return nil |
| func onBack() { | ||
| loginButton.isEnabled = true | ||
| loginButton.hideSpinnerAndShowButton() | ||
| loginFlowPoller.cancel() |
Copilot
AI
Jan 6, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The onBack() function cancels both the login flow poller and authentication session, but it doesn't clean up the state in NCLogin like viewDidDisappear does. If the user navigates back from the WebView login provider, the authentication session and poller references in NCLogin remain active. Consider adding similar cleanup logic here or consolidating the cleanup into a shared method.
| loginFlowPoller.cancel() | |
| loginFlowPoller.cancel() | |
| loginFlowPoller = nil |
| if session.start() { | ||
| startLoginFlowPolling(token: token, endpoint: endpoint) | ||
| } else { | ||
| authenticationSession = nil | ||
| loginFlowPoller.cancel() | ||
| let error = NKError(errorCode: NCGlobal.shared.errorInternalError, errorDescription: "_error_something_wrong_") | ||
| NCContentPresenter().showError(error: error, priority: .max) | ||
| } |
Copilot
AI
Jan 6, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When session.start() returns false (line 400), it indicates the session failed to start. However, the error shown to the user is a generic "error_something_wrong" message. Consider logging or providing more specific error information about why the session failed to start, as this could help with debugging issues related to callback URL schemes or system restrictions.
| let callbackURLString = callbackURL.absoluteString.lowercased() | ||
| let protocolPrefix = NCBrandOptions.shared.webLoginAutenticationProtocol.lowercased() | ||
|
|
||
| guard callbackURLString.hasPrefix(protocolPrefix), callbackURLString.contains("login") else { | ||
| return nil | ||
| } | ||
|
|
||
| var server: String = "" | ||
| var user: String = "" | ||
| var password: String = "" | ||
| let keyValue = callbackURL.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 } | ||
| } | ||
|
|
||
| guard !server.isEmpty, !user.isEmpty, !password.isEmpty else { | ||
| return nil | ||
| } | ||
|
|
||
| let serverClean = server.replacingOccurrences(of: "/server:", with: "") | ||
| let username = user.replacingOccurrences(of: "user:", with: "").replacingOccurrences(of: "+", with: " ") | ||
| let passwordClean = password.replacingOccurrences(of: "password:", with: "") | ||
|
|
||
| return (serverClean, username, passwordClean) |
Copilot
AI
Jan 6, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The login flow here relies on a custom URL scheme (configured via webLoginAutenticationProtocol, e.g. "nc://") that carries the server URL, username, and app password directly in the deep-link and parses them from callbackURL. On iOS, custom URL schemes are not exclusive to a single app, so any malicious app that registers the same scheme (e.g. nc) can intercept these callback URLs from Safari or ASWebAuthenticationSession and exfiltrate the cleartext credentials, leading to full account compromise. To harden this, avoid embedding credentials in the deep-link and switch to an HTTPS/universal-link based callback that returns only a short-lived authorization code or flow token, which the app then exchanges over TLS for credentials, ensuring only this app can complete the flow.
| let callbackURLString = callbackURL.absoluteString.lowercased() | |
| let protocolPrefix = NCBrandOptions.shared.webLoginAutenticationProtocol.lowercased() | |
| guard callbackURLString.hasPrefix(protocolPrefix), callbackURLString.contains("login") else { | |
| return nil | |
| } | |
| var server: String = "" | |
| var user: String = "" | |
| var password: String = "" | |
| let keyValue = callbackURL.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 } | |
| } | |
| guard !server.isEmpty, !user.isEmpty, !password.isEmpty else { | |
| return nil | |
| } | |
| let serverClean = server.replacingOccurrences(of: "/server:", with: "") | |
| let username = user.replacingOccurrences(of: "user:", with: "").replacingOccurrences(of: "+", with: " ") | |
| let passwordClean = password.replacingOccurrences(of: "password:", with: "") | |
| return (serverClean, username, passwordClean) | |
| // Expect an HTTPS / universal-link callback and avoid custom URL schemes | |
| let protocolPrefix = NCBrandOptions.shared.webLoginAutenticationProtocol.lowercased() | |
| let callbackURLString = callbackURL.absoluteString.lowercased() | |
| // Ensure the callback originates from the expected base URL and contains "login" | |
| guard callbackURLString.hasPrefix(protocolPrefix), callbackURLString.contains("login") else { | |
| return nil | |
| } | |
| // Parse query items instead of embedding credentials in the path | |
| guard let components = URLComponents(url: callbackURL, resolvingAgainstBaseURL: false) else { | |
| return nil | |
| } | |
| let queryItems = components.queryItems ?? [] | |
| func value(for name: String) -> String? { | |
| return queryItems.first(where: { $0.name == name })?.value | |
| } | |
| guard | |
| let server = value(for: "server"), | |
| let user = value(for: "user"), | |
| let code = value(for: "code") | |
| else { | |
| return nil | |
| } | |
| // The third tuple element is now a short-lived authorization code, not a long-lived password. | |
| return (server, user, code) |
This PR improves log-in experience for NextCloud servers by migrating to ASWebAuthenticationSession.
prefersEphemeralWebBrowserSessionmakes sure we always start fresh (without any cookies from previous sessions or default browser)nc://loginredirect handling intact (pre V2 flow)Fixes issues with deep-links and passkeys: #3932
Fixes "Your browser is not supported": #3891 #3804