Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Brand/Intro/NCIntroViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -164,7 +164,7 @@ class NCIntroViewController: UIViewController, UICollectionViewDataSource, UICol
}

@IBAction func signupWithProvider(_ sender: Any) {
if let viewController = UIStoryboard(name: "NCLogin", bundle: nil).instantiateViewController(withIdentifier: "NCLoginProvider") as? NCLoginProvider {
if let viewController = UIStoryboard(name: "NCLogin", bundle: nil).instantiateViewController(withIdentifier: "NCLoginProvider") as? NCWebViewLoginProvider {
viewController.controller = self.controller
viewController.initialURLString = NCBrandOptions.shared.linkloginPreferredProviders
self.navigationController?.pushViewController(viewController, animated: true)
Expand Down
16 changes: 12 additions & 4 deletions Nextcloud.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@
AABD0C8A2D5F67A400F009E6 /* XCUIElement.swift in Sources */ = {isa = PBXBuildFile; fileRef = AABD0C892D5F67A200F009E6 /* XCUIElement.swift */; };
AABD0C9B2D5F73FC00F009E6 /* Placeholder.swift in Sources */ = {isa = PBXBuildFile; fileRef = AABD0C9A2D5F73FA00F009E6 /* Placeholder.swift */; };
AAE330042D2ED20200B04903 /* NCShareNavigationTitleSetting.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAE330032D2ED1FF00B04903 /* NCShareNavigationTitleSetting.swift */; };
AC72BE742EFEF4AD006ACB5F /* NCProviderLoginHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7ACF3912F3E697700567B56 /* NCProviderLoginHandler.swift */; };
AF1A9B6427D0CA1E00F17A9E /* UIAlertController+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = AF1A9B6327D0CA1E00F17A9E /* UIAlertController+Extension.swift */; };
AF1A9B6527D0CC0500F17A9E /* UIAlertController+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = AF1A9B6327D0CA1E00F17A9E /* UIAlertController+Extension.swift */; };
AF22B206277B4E4C00DAB0CC /* NCCreateFormUploadConflict.swift in Sources */ = {isa = PBXBuildFile; fileRef = F704B5E42430AA8000632F5F /* NCCreateFormUploadConflict.swift */; };
Expand Down Expand Up @@ -693,7 +694,8 @@
F7A8D74428F1827B008BBE1C /* ThreadSafeDictionary.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7245923289BB50B00474787 /* ThreadSafeDictionary.swift */; };
F7AC1CB028AB94490032D99F /* Array+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7AC1CAF28AB94490032D99F /* Array+Extension.swift */; };
F7AC934A296193050002BC0F /* Reasons to use Nextcloud.pdf in Resources */ = {isa = PBXBuildFile; fileRef = F7AC9349296193050002BC0F /* Reasons to use Nextcloud.pdf */; };
F7AE00F5230D5F9E007ACF8A /* NCLoginProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7AE00F4230D5F9E007ACF8A /* NCLoginProvider.swift */; };
F7ACF26E2F3CB59300567B56 /* NCLoginFlowPoller.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7ACF26D2F3CB59300567B56 /* NCLoginFlowPoller.swift */; };
F7AE00F5230D5F9E007ACF8A /* NCWebViewLoginProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7AE00F4230D5F9E007ACF8A /* NCWebViewLoginProvider.swift */; };
F7AE00F8230E81CB007ACF8A /* NCBrowserWeb.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7AE00F7230E81CB007ACF8A /* NCBrowserWeb.swift */; };
F7AE00FA230E81EB007ACF8A /* NCBrowserWeb.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = F7AE00F9230E81EB007ACF8A /* NCBrowserWeb.storyboard */; };
F7B398422A6A91D5007538D6 /* NCSectionFirstHeader.xib in Resources */ = {isa = PBXBuildFile; fileRef = F7B398412A6A91D5007538D6 /* NCSectionFirstHeader.xib */; };
Expand Down Expand Up @@ -1611,7 +1613,9 @@
F7AA41E127C7CF8100494705 /* es-MX */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "es-MX"; path = "es-MX.lproj/InfoPlist.strings"; sourceTree = "<group>"; };
F7AC1CAF28AB94490032D99F /* Array+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Array+Extension.swift"; sourceTree = "<group>"; };
F7AC9349296193050002BC0F /* Reasons to use Nextcloud.pdf */ = {isa = PBXFileReference; lastKnownFileType = image.pdf; path = "Reasons to use Nextcloud.pdf"; sourceTree = SOURCE_ROOT; };
F7AE00F4230D5F9E007ACF8A /* NCLoginProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCLoginProvider.swift; sourceTree = "<group>"; };
F7ACF26D2F3CB59300567B56 /* NCLoginFlowPoller.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCLoginFlowPoller.swift; sourceTree = "<group>"; };
F7ACF3912F3E697700567B56 /* NCProviderLoginHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCProviderLoginHandler.swift; sourceTree = "<group>"; };
F7AE00F4230D5F9E007ACF8A /* NCWebViewLoginProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCWebViewLoginProvider.swift; sourceTree = "<group>"; };
F7AE00F7230E81CB007ACF8A /* NCBrowserWeb.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCBrowserWeb.swift; sourceTree = "<group>"; };
F7AE00F9230E81EB007ACF8A /* NCBrowserWeb.storyboard */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; path = NCBrowserWeb.storyboard; sourceTree = "<group>"; };
F7B1A7761EBB3C8000BFB6D1 /* de */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = de; path = de.lproj/Localizable.strings; sourceTree = "<group>"; };
Expand Down Expand Up @@ -2896,7 +2900,9 @@
F702F2F025EE5CDA008F8E80 /* NCLogin.storyboard */,
F702F2F625EE5CEC008F8E80 /* NCLogin.swift */,
F745B252222D88AE00346520 /* NCLoginQRCode.swift */,
F7AE00F4230D5F9E007ACF8A /* NCLoginProvider.swift */,
F7ACF26D2F3CB59300567B56 /* NCLoginFlowPoller.swift */,
F7ACF3912F3E697700567B56 /* NCProviderLoginHandler.swift */,
F7AE00F4230D5F9E007ACF8A /* NCWebViewLoginProvider.swift */,
F7BC287D26663F6C004D46C5 /* NCViewCertificateDetails.storyboard */,
F7BC287F26663F85004D46C5 /* NCViewCertificateDetails.swift */,
);
Expand Down Expand Up @@ -4536,6 +4542,7 @@
F7E7AEA52BA32C6500512E52 /* NCCollectionViewDownloadThumbnail.swift in Sources */,
AF730AF827834B1400B7520E /* NCShare+NCCellDelegate.swift in Sources */,
F70460522499061800BB98A7 /* NotificationCenter+MainThread.swift in Sources */,
AC72BE742EFEF4AD006ACB5F /* NCProviderLoginHandler.swift in Sources */,
F3C587AE2D47E4FE004532DB /* PHAssetCollectionThumbnailLoader.swift in Sources */,
F78F74362163781100C2ADAD /* NCTrash.swift in Sources */,
F39298972A3B12CB00509762 /* BaseNCMoreCell.swift in Sources */,
Expand Down Expand Up @@ -4648,7 +4655,8 @@
F74C0436253F1CDC009762AB /* NCShares.swift in Sources */,
F79699E72E689F68000EC82A /* NCMediaNavigationController.swift in Sources */,
F7AC1CB028AB94490032D99F /* Array+Extension.swift in Sources */,
F7AE00F5230D5F9E007ACF8A /* NCLoginProvider.swift in Sources */,
F7ACF26E2F3CB59300567B56 /* NCLoginFlowPoller.swift in Sources */,
F7AE00F5230D5F9E007ACF8A /* NCWebViewLoginProvider.swift in Sources */,
F707C26521A2DC5200F6181E /* NCStoreReview.swift in Sources */,
F7CF06802E0FF3990063AD04 /* NCAppStateManager.swift in Sources */,
F7BAADCB1ED5A87C00B7EAD4 /* NCManageDatabase.swift in Sources */,
Expand Down
4 changes: 2 additions & 2 deletions iOSClient/Login/NCLogin.storyboard
Original file line number Diff line number Diff line change
Expand Up @@ -147,10 +147,10 @@
</objects>
<point key="canvasLocation" x="5389.0909090909099" y="-1211.9246861924687"/>
</scene>
<!--Login Provider-->
<!--Web View Login Provider-->
<scene sceneID="3Rv-vf-u17">
<objects>
<viewController storyboardIdentifier="NCLoginProvider" id="yEb-Ky-35s" customClass="NCLoginProvider" customModule="Nextcloud" customModuleProvider="target" sceneMemberID="viewController">
<viewController storyboardIdentifier="NCLoginProvider" id="yEb-Ky-35s" customClass="NCWebViewLoginProvider" customModule="Nextcloud" customModuleProvider="target" sceneMemberID="viewController">
<view key="view" contentMode="scaleToFill" id="UX5-cJ-bY6">
<rect key="frame" x="0.0" y="0.0" width="440" height="956"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
Expand Down
99 changes: 91 additions & 8 deletions iOSClient/Login/NCLogin.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import UIKit
import NextcloudKit
import SwiftEntryKit
import SwiftUI
import SafariServices
import AuthenticationServices

class NCLogin: UIViewController, UITextFieldDelegate, NCLoginQRCodeDelegate {
@IBOutlet weak var imageBrand: UIImageView!
Expand All @@ -28,6 +28,8 @@ class NCLogin: UIViewController, UITextFieldDelegate, NCLoginQRCodeDelegate {
private var activeTextField = UITextField()

private var shareAccounts: [NKShareAccounts.DataAccounts]?
private let loginFlowPoller = NCLoginFlowPoller()
private var authenticationSession: ASWebAuthenticationSession?

/// Controller
var controller: NCMainTabBarController?
Expand Down Expand Up @@ -197,6 +199,16 @@ class NCLogin: UIViewController, UITextFieldDelegate, NCLoginQRCodeDelegate {
}
}

override func viewDidDisappear(_ animated: Bool) {
super.viewDidDisappear(animated)

if isMovingFromParent || isBeingDismissed {
loginFlowPoller.cancel()
authenticationSession?.cancel()
authenticationSession = nil
}
}

private func handleLoginWithAppConfig() {
let accountCount = NCManageDatabase.shared.getAccounts()?.count ?? 0

Expand Down Expand Up @@ -330,14 +342,9 @@ class NCLogin: UIViewController, UITextFieldDelegate, NCLoginQRCodeDelegate {
let loginOptions = NKRequestOptions(customUserAgent: userAgent)
NextcloudKit.shared.getLoginFlowV2(serverUrl: url, options: loginOptions) { [self] token, endpoint, login, _, error in
// Login Flow V2
if error == .success, let token, let endpoint, let login {
if error == .success, let token, let endpoint, let login, let loginURL = URL(string: 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)
startASWebAuthenticationSession(loginURL: loginURL, token: token, endpoint: endpoint)
}
}
case .failure(let error):
Expand Down Expand Up @@ -371,6 +378,71 @@ class NCLogin: UIViewController, UITextFieldDelegate, NCLoginQRCodeDelegate {
}
}

private func startLoginFlowPolling(token: String, endpoint: String) {
loginFlowPoller.start(token: token, endpoint: endpoint) { [weak self] grant in
// Finish login v2 flow
await self?.handleLoginGrant(grant)
}
}

private func startASWebAuthenticationSession(loginURL: URL, token: String, endpoint: String) {
let callbackScheme = URL(string: NCBrandOptions.shared.webLoginAutenticationProtocol)?.scheme ?? loginURL.scheme
Copy link

Copilot AI Jan 6, 2026

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.

Suggested change
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
}

Copilot uses AI. Check for mistakes.

let session = ASWebAuthenticationSession(url: loginURL,
callbackURLScheme: callbackScheme,
completionHandler: handleAuthenticationSessionCompletion(callbackURL:error:))

session.presentationContextProvider = self
session.prefersEphemeralWebBrowserSession = true

authenticationSession = session

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)
}
Comment on lines +400 to +407
Copy link

Copilot AI Jan 6, 2026

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.

Copilot uses AI. Check for mistakes.
}

@MainActor
private func handleLoginGrant(_ grant: NCLoginGrant) async {
authenticationSession?.cancel()
authenticationSession = nil
createAccount(urlBase: grant.urlBase, user: grant.loginName, password: grant.appPassword)
}

@MainActor
Copy link

Copilot AI Jan 6, 2026

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.

Suggested change
@MainActor

Copilot uses AI. Check for mistakes.
private func handleAuthenticationSessionCompletion(callbackURL: URL?, error: Error?) {
loginFlowPoller.cancel()
authenticationSession = nil

if let error = error as? ASWebAuthenticationSessionError, error.code == .canceledLogin {
// Do nothing as user canceled login flow
return
}

if let error = error {
nkLog(error: "ASWebAuthenticationSession failed with error: \(error.localizedDescription)")
let error = NKError(errorCode: NCGlobal.shared.errorInternalError, errorDescription: "_error_something_wrong_")
NCContentPresenter().showError(error: error, priority: .max)
return
}

guard let callbackURL, let providerGrant = NCProviderLoginHandler.handle(callbackURL: callbackURL) else {
nkLog(error: "ASWebAuthenticationSession returned an invalid callback URL.")
let error = NKError(errorCode: NCGlobal.shared.errorInternalError, errorDescription: "_error_something_wrong_")
NCContentPresenter().showError(error: error, priority: .max)
return
}

Task {
await handleLoginGrant(NCLoginGrant(urlBase: providerGrant.urlBase, loginName: providerGrant.user, appPassword: providerGrant.password))
Comment on lines +441 to +442
Copy link

Copilot AI Jan 6, 2026

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.

Copilot uses AI. Check for mistakes.
}
}

// MARK: - QRCode

func dismissQRCode(_ value: String?, metadataType: String?) {
Expand Down Expand Up @@ -482,5 +554,16 @@ extension NCLogin: NCLoginProviderDelegate {
func onBack() {
loginButton.isEnabled = true
loginButton.hideSpinnerAndShowButton()
loginFlowPoller.cancel()
Copy link

Copilot AI Jan 6, 2026

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.

Suggested change
loginFlowPoller.cancel()
loginFlowPoller.cancel()
loginFlowPoller = nil

Copilot uses AI. Check for mistakes.
authenticationSession?.cancel()
authenticationSession = nil
}
}

// MARK: - ASWebAuthenticationPresentationContextProviding

extension NCLogin: ASWebAuthenticationPresentationContextProviding {
func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor {
view.window ?? UIWindow()
}
}
98 changes: 98 additions & 0 deletions iOSClient/Login/NCLoginFlowPoller.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
// SPDX-FileCopyrightText: Nextcloud GmbH
// SPDX-License-Identifier: GPL-3.0-or-later

import Foundation
import NextcloudKit

struct NCLoginGrant {
let urlBase: String
let loginName: String
Copy link

Copilot AI Jan 6, 2026

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.

Copilot uses AI. Check for mistakes.
let appPassword: String
}

final class NCLoginFlowPoller {
private var pollingTask: Task<Void, Never>?

func start(token: String, endpoint: String, onGrant: @escaping @MainActor (NCLoginGrant) async -> Void) {
cancel()

let options = NKRequestOptions(customUserAgent: userAgent)
nkLog(start: "Starting polling at \(endpoint) with token \(token)")

pollingTask = Task { [weak self] in
guard let self else { return }
defer { self.pollingTask = nil }

guard let grantValues = await self.waitForGrant(token: token, endpoint: endpoint, options: options) else {
return
}

await onGrant(grantValues)
nkLog(debug: "Login flow polling task completed.")
}

nkLog(debug: "Login flow polling task created.")
}

func cancel() {
guard pollingTask != nil else {
return
}

nkLog(debug: "Cancelling login polling task...")
pollingTask?.cancel()
pollingTask = nil
}

private func waitForGrant(token: String, endpoint: String, options: NKRequestOptions) async -> NCLoginGrant? {
var grantValues: NCLoginGrant?

repeat {
guard !Task.isCancelled else {
nkLog(debug: "Login polling task cancelled before receiving grant values.")
return nil
}

grantValues = await pollOnce(token: token, endpoint: endpoint, options: options)
if grantValues == nil {
try? await Task.sleep(nanoseconds: 1_000_000_000) // .seconds() is not supported on iOS 15 yet.
}
} while grantValues == nil

return grantValues
}

private func pollOnce(token: String, endpoint: String, options: NKRequestOptions) async -> NCLoginGrant? {
await withCheckedContinuation { continuation 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!")
continuation.resume(returning: nil)
return
}

guard let urlBase = server else {
nkLog(error: "Login poll response field for server for token \"\(token)\" is nil!")
continuation.resume(returning: nil)
return
}

guard let user = loginName else {
nkLog(error: "Login poll response field for user name for token \"\(token)\" is nil!")
continuation.resume(returning: nil)
return
}

guard let password = appPassword else {
nkLog(error: "Login poll response field for app password for token \"\(token)\" is nil!")
continuation.resume(returning: nil)
return
}

nkLog(debug: "Returning login poll response for \"\(user)\" on \"\(urlBase)\" for token \"\(token)\".")
continuation.resume(returning: NCLoginGrant(urlBase: urlBase, loginName: user, appPassword: password))
}
}
}
}
38 changes: 38 additions & 0 deletions iOSClient/Login/NCProviderLoginHandler.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
// SPDX-FileCopyrightText: Nextcloud GmbH
// SPDX-FileCopyrightText: 2025 Iva Horn
// SPDX-FileCopyrightText: 2025 Milen Pivchev
// SPDX-License-Identifier: GPL-3.0-or-later

import Foundation

struct NCProviderLoginHandler {
static func handle(callbackURL: URL) -> (urlBase: String, user: String, password: String)? {
let callbackURLString = callbackURL.absoluteString.lowercased()
let protocolPrefix = NCBrandOptions.shared.webLoginAutenticationProtocol.lowercased()

guard callbackURLString.hasPrefix(protocolPrefix), callbackURLString.contains("login") else {
return nil
}
Comment on lines +10 to +15
Copy link

Copilot AI Jan 6, 2026

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.

Suggested change
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

Copilot uses AI. Check for mistakes.

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 }
Comment on lines +23 to +25
Copy link

Copilot AI Jan 6, 2026

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.

Suggested change
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 }

Copilot uses AI. Check for mistakes.
}

guard !server.isEmpty, !user.isEmpty, !password.isEmpty else {
return nil
}

let serverClean = server.replacingOccurrences(of: "/server:", with: "")
Copy link

Copilot AI Jan 6, 2026

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.

Suggested change
let serverClean = server.replacingOccurrences(of: "/server:", with: "")
let serverClean = server
.replacingOccurrences(of: "/server:", with: "")
.replacingOccurrences(of: "server:", with: "")

Copilot uses AI. Check for mistakes.
let username = user.replacingOccurrences(of: "user:", with: "").replacingOccurrences(of: "+", with: " ")
let passwordClean = password.replacingOccurrences(of: "password:", with: "")

return (serverClean, username, passwordClean)
Comment on lines +10 to +36
Copy link

Copilot AI Jan 6, 2026

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.

Suggested change
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)

Copilot uses AI. Check for mistakes.
}
}
Loading