From acfa573149b19aacc2697984b0828334da89920d Mon Sep 17 00:00:00 2001 From: Thomas Dhooghe <61279337+tdhooghe@users.noreply.github.com> Date: Sun, 8 Mar 2026 14:51:10 +0100 Subject: [PATCH 1/3] fix(iOS): use ASWebAuthenticationSession instead of WKWebView for login MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WKWebView silently fails to complete cross-domain OIDC redirects (e.g. when Nextcloud delegates authentication to an external IdP like Authentik). The user authenticates successfully on the IdP side, but WKWebView drops the callback redirect back to the Nextcloud origin, leaving the login flow stuck in a polling loop that never resolves. Replace WKWebView with ASWebAuthenticationSession on iOS via a new LoginSheet view modifier that encapsulates the platform difference: - iOS: ASWebAuthenticationSession (system browser, handles OIDC/passkeys) - macOS: WKWebView sheet (unchanged behavior) ServerAddressView is now platform-agnostic — it just sets isPresented and the modifier does the right thing per platform. Credentials continue to be obtained via the host app's existing polling mechanism. Ref: nextcloud/ios#3996 (same fix applied to the main iOS app) Signed-off-by: Thomas Dhooghe <61279337+tdhooghe@users.noreply.github.com> --- .../Modifiers/LoginSheet.swift | 111 ++++++++++++++++++ .../Views/ServerAddressView.swift | 8 +- 2 files changed, 115 insertions(+), 4 deletions(-) create mode 100644 Sources/SwiftNextcloudUI/Modifiers/LoginSheet.swift diff --git a/Sources/SwiftNextcloudUI/Modifiers/LoginSheet.swift b/Sources/SwiftNextcloudUI/Modifiers/LoginSheet.swift new file mode 100644 index 0000000..f705c9c --- /dev/null +++ b/Sources/SwiftNextcloudUI/Modifiers/LoginSheet.swift @@ -0,0 +1,111 @@ +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2026 tdhooghe +// SPDX-License-Identifier: GPL-3.0-or-later + +import SwiftUI + +#if os(iOS) +import AuthenticationServices +#endif + +/// +/// Presents the Nextcloud Login Flow v2 authentication UI. +/// +/// On **iOS**, this uses `ASWebAuthenticationSession` which opens a system browser sheet +/// that properly handles cross-domain OIDC redirects, passkeys, and deep links. +/// +/// On **macOS**, this presents a ``WebView`` in a sheet. +/// +/// Credentials are obtained via the host app's polling mechanism on both platforms. +/// +struct LoginSheet: ViewModifier { + let userAgent: String? + let onDismiss: () -> Void + + @Binding var loginURL: URL? + @Binding var isPresented: Bool + + #if os(iOS) + @State private var authSession: ASWebAuthenticationSession? + @State private var sessionCoordinator = SessionCoordinator() + #endif + + init(loginURL: Binding, isPresented: Binding, userAgent: String?, onDismiss: @escaping () -> Void) { + self._loginURL = loginURL + self._isPresented = isPresented + self.userAgent = userAgent + self.onDismiss = onDismiss + } + + func body(content: Content) -> some View { + content + #if os(macOS) + .sheet(isPresented: $isPresented, onDismiss: onDismiss) { + WebView(initialURL: $loginURL, userAgent: userAgent) + .ignoresSafeArea() + .frame(minWidth: 800, minHeight: 800) + } + #else + .onChange(of: isPresented) { _, presented in + if presented, let url = loginURL { + startAuthSession(url: url) + } else { + authSession?.cancel() + authSession = nil + } + } + #endif + } + + #if os(iOS) + + private func startAuthSession(url: URL) { + let session = ASWebAuthenticationSession(url: url, callbackURLScheme: "nc") { _, error in + authSession = nil + if let error = error as? ASWebAuthenticationSessionError, error.code == .canceledLogin { + onDismiss() + } + } + + session.presentationContextProvider = sessionCoordinator + session.prefersEphemeralWebBrowserSession = true + authSession = session + session.start() + } + + #endif +} + +extension View { + /// + /// Present the login authentication UI appropriate for the current platform. + /// + /// See ``LoginSheet`` for the implementation. + /// + func loginSheet(loginURL: Binding, isPresented: Binding, userAgent: String?, onDismiss: @escaping () -> Void) -> some View { + modifier(LoginSheet(loginURL: loginURL, isPresented: isPresented, userAgent: userAgent, onDismiss: onDismiss)) + } +} + +// MARK: - ASWebAuthenticationSession Coordinator + +#if os(iOS) + +/// +/// Provides the presentation anchor for `ASWebAuthenticationSession` in SwiftUI contexts. +/// +@MainActor +private class SessionCoordinator: NSObject, ASWebAuthenticationPresentationContextProviding { + nonisolated func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor { + return MainActor.assumeIsolated { + guard let windowScene = UIApplication.shared.connectedScenes.first(where: { $0.activationState == .foregroundActive }) as? UIWindowScene, + let window = windowScene.windows.first(where: \.isKeyWindow) + else { + return ASPresentationAnchor() + } + return window + } + } +} + +#endif diff --git a/Sources/SwiftNextcloudUI/Views/ServerAddressView.swift b/Sources/SwiftNextcloudUI/Views/ServerAddressView.swift index 272b893..79fb90c 100644 --- a/Sources/SwiftNextcloudUI/Views/ServerAddressView.swift +++ b/Sources/SwiftNextcloudUI/Views/ServerAddressView.swift @@ -216,7 +216,7 @@ public struct ServerAddressView: View, QRCodeParsing, URLSanitizing { .safeAreaPadding(.all) } .ignoresSafeArea() - .webSheet(initialURL: $loginAddress, isPresented: $isPresentingWebView, userAgent: userAgent, onDismiss: endWebView) + .loginSheet(loginURL: $loginAddress, isPresented: $isPresentingWebView, userAgent: userAgent, onDismiss: endWebView) .alert(String(localized: "Login Failed", comment: "Alert title"), isPresented: $isPresentingAlert) { Button(role: .cancel) { errorMessage = nil @@ -294,12 +294,12 @@ public struct ServerAddressView: View, QRCodeParsing, URLSanitizing { } /// - /// Dismisses the sheet with the web view. + /// Dismisses the login UI. /// /// Multiple paths can lead to here. In example: /// - /// - The user dismisses the sheet without logging in. - /// - The login completed successfully. + /// - The user dismisses the session without logging in. + /// - The login completed successfully (detected by polling). /// func endWebView() { if let pollingToken { From a51116fb4df4cf8801de0909f118ae52ef6c58a9 Mon Sep 17 00:00:00 2001 From: Thomas Dhooghe <61279337+tdhooghe@users.noreply.github.com> Date: Tue, 10 Mar 2026 10:31:21 +0100 Subject: [PATCH 2/3] fix(lint): match project file header pattern and use implicit return Signed-off-by: Thomas Dhooghe <61279337+tdhooghe@users.noreply.github.com> Made-with: Cursor --- Sources/SwiftNextcloudUI/Modifiers/LoginSheet.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Sources/SwiftNextcloudUI/Modifiers/LoginSheet.swift b/Sources/SwiftNextcloudUI/Modifiers/LoginSheet.swift index f705c9c..5fd6d26 100644 --- a/Sources/SwiftNextcloudUI/Modifiers/LoginSheet.swift +++ b/Sources/SwiftNextcloudUI/Modifiers/LoginSheet.swift @@ -1,5 +1,5 @@ // SPDX-FileCopyrightText: Nextcloud GmbH -// SPDX-FileCopyrightText: 2026 tdhooghe +// SPDX-FileCopyrightText: 2025 tdhooghe // SPDX-License-Identifier: GPL-3.0-or-later import SwiftUI @@ -97,7 +97,7 @@ extension View { @MainActor private class SessionCoordinator: NSObject, ASWebAuthenticationPresentationContextProviding { nonisolated func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor { - return MainActor.assumeIsolated { + MainActor.assumeIsolated { guard let windowScene = UIApplication.shared.connectedScenes.first(where: { $0.activationState == .foregroundActive }) as? UIWindowScene, let window = windowScene.windows.first(where: \.isKeyWindow) else { From 982e0e096876d7a0e1a87d9bb8a524e0ac5445c1 Mon Sep 17 00:00:00 2001 From: Thomas Dhooghe <61279337+tdhooghe@users.noreply.github.com> Date: Thu, 18 Jun 2026 11:48:07 +0200 Subject: [PATCH 3/3] refactor: unify login on SwiftUI webAuthenticationSession Replace the per-platform split (ASWebAuthenticationSession on iOS, WKWebView sheet on macOS) with SwiftUI's webAuthenticationSession, which provides the presentation anchor on both platforms. Drops the SessionCoordinator and the #if os branches. macOS now goes through the system browser instead of WKWebView, so the same OIDC redirect handling applies there too. Login Flow v2 has no callback to complete, so the session is cancelled once polling reports success (isPresented becomes false). Stop forwarding userAgent to the login UI since the system browser can't set a custom UA. The app password name is unaffected, it comes from the host app's /login/v2 request. Signed-off-by: Thomas Dhooghe <61279337+tdhooghe@users.noreply.github.com> --- .../Modifiers/LoginSheet.swift | 105 ++++++------------ .../Views/ServerAddressView.swift | 2 +- 2 files changed, 33 insertions(+), 74 deletions(-) diff --git a/Sources/SwiftNextcloudUI/Modifiers/LoginSheet.swift b/Sources/SwiftNextcloudUI/Modifiers/LoginSheet.swift index 5fd6d26..4a0bfae 100644 --- a/Sources/SwiftNextcloudUI/Modifiers/LoginSheet.swift +++ b/Sources/SwiftNextcloudUI/Modifiers/LoginSheet.swift @@ -4,108 +4,67 @@ import SwiftUI -#if os(iOS) -import AuthenticationServices -#endif - -/// -/// Presents the Nextcloud Login Flow v2 authentication UI. /// -/// On **iOS**, this uses `ASWebAuthenticationSession` which opens a system browser sheet -/// that properly handles cross-domain OIDC redirects, passkeys, and deep links. +/// Presents the Nextcloud Login Flow v2 authentication UI using the system browser. /// -/// On **macOS**, this presents a ``WebView`` in a sheet. +/// Uses SwiftUI's `webAuthenticationSession`, which handles cross-domain OIDC redirects, +/// passkeys, and deep links on both iOS and macOS. /// -/// Credentials are obtained via the host app's polling mechanism on both platforms. +/// Credentials are obtained via the host app's polling mechanism. The browser session has +/// no callback to complete, so it is cancelled once polling reports success (when +/// `isPresented` becomes `false`). /// struct LoginSheet: ViewModifier { - let userAgent: String? + @Environment(\.webAuthenticationSession) private var webAuthenticationSession + let onDismiss: () -> Void @Binding var loginURL: URL? @Binding var isPresented: Bool - #if os(iOS) - @State private var authSession: ASWebAuthenticationSession? - @State private var sessionCoordinator = SessionCoordinator() - #endif + @State private var authenticationTask: Task? - init(loginURL: Binding, isPresented: Binding, userAgent: String?, onDismiss: @escaping () -> Void) { + init(loginURL: Binding, isPresented: Binding, onDismiss: @escaping () -> Void) { self._loginURL = loginURL self._isPresented = isPresented - self.userAgent = userAgent self.onDismiss = onDismiss } func body(content: Content) -> some View { - content - #if os(macOS) - .sheet(isPresented: $isPresented, onDismiss: onDismiss) { - WebView(initialURL: $loginURL, userAgent: userAgent) - .ignoresSafeArea() - .frame(minWidth: 800, minHeight: 800) - } - #else - .onChange(of: isPresented) { _, presented in - if presented, let url = loginURL { - startAuthSession(url: url) - } else { - authSession?.cancel() - authSession = nil - } + content.onChange(of: isPresented) { _, presented in + if presented, let loginURL { + startAuthentication(url: loginURL) + } else { + authenticationTask?.cancel() + authenticationTask = nil } - #endif + } } - #if os(iOS) - - private func startAuthSession(url: URL) { - let session = ASWebAuthenticationSession(url: url, callbackURLScheme: "nc") { _, error in - authSession = nil - if let error = error as? ASWebAuthenticationSessionError, error.code == .canceledLogin { - onDismiss() + private func startAuthentication(url: URL) { + authenticationTask = Task { + defer { authenticationTask = nil } + do { + _ = try await webAuthenticationSession.authenticate( + using: url, + callbackURLScheme: "nc", + preferredBrowserSession: .ephemeral) + } catch { + if !Task.isCancelled { + onDismiss() + } } } - - session.presentationContextProvider = sessionCoordinator - session.prefersEphemeralWebBrowserSession = true - authSession = session - session.start() } - - #endif } extension View { /// - /// Present the login authentication UI appropriate for the current platform. + /// Present the Nextcloud login authentication UI using the system browser. /// /// See ``LoginSheet`` for the implementation. /// - func loginSheet(loginURL: Binding, isPresented: Binding, userAgent: String?, onDismiss: @escaping () -> Void) -> some View { - modifier(LoginSheet(loginURL: loginURL, isPresented: isPresented, userAgent: userAgent, onDismiss: onDismiss)) + func loginSheet(loginURL: Binding, isPresented: Binding, onDismiss: @escaping () -> Void) -> some View { + modifier(LoginSheet(loginURL: loginURL, isPresented: isPresented, onDismiss: onDismiss)) } } - -// MARK: - ASWebAuthenticationSession Coordinator - -#if os(iOS) - -/// -/// Provides the presentation anchor for `ASWebAuthenticationSession` in SwiftUI contexts. -/// -@MainActor -private class SessionCoordinator: NSObject, ASWebAuthenticationPresentationContextProviding { - nonisolated func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor { - MainActor.assumeIsolated { - guard let windowScene = UIApplication.shared.connectedScenes.first(where: { $0.activationState == .foregroundActive }) as? UIWindowScene, - let window = windowScene.windows.first(where: \.isKeyWindow) - else { - return ASPresentationAnchor() - } - return window - } - } -} - -#endif diff --git a/Sources/SwiftNextcloudUI/Views/ServerAddressView.swift b/Sources/SwiftNextcloudUI/Views/ServerAddressView.swift index 79fb90c..88c05eb 100644 --- a/Sources/SwiftNextcloudUI/Views/ServerAddressView.swift +++ b/Sources/SwiftNextcloudUI/Views/ServerAddressView.swift @@ -216,7 +216,7 @@ public struct ServerAddressView: View, QRCodeParsing, URLSanitizing { .safeAreaPadding(.all) } .ignoresSafeArea() - .loginSheet(loginURL: $loginAddress, isPresented: $isPresentingWebView, userAgent: userAgent, onDismiss: endWebView) + .loginSheet(loginURL: $loginAddress, isPresented: $isPresentingWebView, onDismiss: endWebView) .alert(String(localized: "Login Failed", comment: "Alert title"), isPresented: $isPresentingAlert) { Button(role: .cancel) { errorMessage = nil