diff --git a/.gitmodules b/.gitmodules index a764677..6d2268d 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,3 +1,9 @@ [submodule "netbird-core"] path = netbird-core - url = https://github.com/netbirdio/netbird.git + # TEMPORARY: points at the fork's feat/ios-login-hint, which carries the + # login_hint support this branch's SSO login needs (ProfileAccountEmail / + # ClearProfileAccountEmail in client/ios/NetBirdSDK). Revert to + # https://github.com/netbirdio/netbird.git once that lands upstream, and pin + # the submodule to an upstream commit before merging. + url = https://github.com/evgeniyChepelev/netbird.git + branch = feat/ios-login-hint diff --git a/NetBird.xcodeproj/project.pbxproj b/NetBird.xcodeproj/project.pbxproj index 1c6244b..88b17ec 100644 --- a/NetBird.xcodeproj/project.pbxproj +++ b/NetBird.xcodeproj/project.pbxproj @@ -59,7 +59,6 @@ 44F3E3992EE2F90900C87FEC /* libresolv.tbd in Frameworks */ = {isa = PBXBuildFile; fileRef = 44DCF5B82EDF4D900026078E /* libresolv.tbd */; }; 44F3E39B2EE2F9FA00C87FEC /* TVAuthView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 44F3E39A2EE2F9FA00C87FEC /* TVAuthView.swift */; }; 4849965EC2515950756C8F10 /* VPNOnDemandView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9CD257EF78F038560FF3112D /* VPNOnDemandView.swift */; }; - BB001A012F99000000000001 /* TroubleshootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB001A002F99000000000001 /* TroubleshootView.swift */; }; 50003BBC2AFBCA6B00E5EB6B /* FirebasePerformance in Frameworks */ = {isa = PBXBuildFile; productRef = 50003BBB2AFBCA6B00E5EB6B /* FirebasePerformance */; }; 50003BBE2AFBCA7900E5EB6B /* FirebasePerformance in Frameworks */ = {isa = PBXBuildFile; productRef = 50003BBD2AFBCA7900E5EB6B /* FirebasePerformance */; }; 50003BC42AFBD7D500E5EB6B /* PacketTunnelProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50245A562A80431C0034792B /* PacketTunnelProvider.swift */; }; @@ -135,16 +134,16 @@ 50E608132A7958B100BAF09B /* MainViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50E608122A7958B100BAF09B /* MainViewModel.swift */; }; 50E608242A79966600BAF09B /* AboutView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50E608232A79966600BAF09B /* AboutView.swift */; }; 50E608262A79968500BAF09B /* AdvancedView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50E608252A79968500BAF09B /* AdvancedView.swift */; }; + 5573F6EE2F9F523D00E63A73 /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = 5573F6ED2F9F523D00E63A73 /* GoogleService-Info.plist */; }; + 5573F6EF2F9F523D00E63A73 /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = 5573F6ED2F9F523D00E63A73 /* GoogleService-Info.plist */; }; + 5573F6F02F9F523D00E63A73 /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = 5573F6ED2F9F523D00E63A73 /* GoogleService-Info.plist */; }; + 5573F6F12F9F523D00E63A73 /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = 5573F6ED2F9F523D00E63A73 /* GoogleService-Info.plist */; }; 558553FB2FE34921004FB58D /* jetbrains-mono-variable.ttf in Resources */ = {isa = PBXBuildFile; fileRef = 558553FA2FE34921004FB58D /* jetbrains-mono-variable.ttf */; }; 558553FC2FE34921004FB58D /* inter-variable.ttf in Resources */ = {isa = PBXBuildFile; fileRef = 558553F92FE34921004FB58D /* inter-variable.ttf */; }; 558553FD2FE34921004FB58D /* jetbrains-mono-variable.ttf in Resources */ = {isa = PBXBuildFile; fileRef = 558553FA2FE34921004FB58D /* jetbrains-mono-variable.ttf */; }; 558553FE2FE34921004FB58D /* inter-variable.ttf in Resources */ = {isa = PBXBuildFile; fileRef = 558553F92FE34921004FB58D /* inter-variable.ttf */; }; 558553FF2FE34921004FB58D /* jetbrains-mono-variable.ttf in Resources */ = {isa = PBXBuildFile; fileRef = 558553FA2FE34921004FB58D /* jetbrains-mono-variable.ttf */; }; 558554002FE34921004FB58D /* inter-variable.ttf in Resources */ = {isa = PBXBuildFile; fileRef = 558553F92FE34921004FB58D /* inter-variable.ttf */; }; - 5573F6EE2F9F523D00E63A73 /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = 5573F6ED2F9F523D00E63A73 /* GoogleService-Info.plist */; }; - 5573F6EF2F9F523D00E63A73 /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = 5573F6ED2F9F523D00E63A73 /* GoogleService-Info.plist */; }; - 5573F6F02F9F523D00E63A73 /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = 5573F6ED2F9F523D00E63A73 /* GoogleService-Info.plist */; }; - 5573F6F12F9F523D00E63A73 /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = 5573F6ED2F9F523D00E63A73 /* GoogleService-Info.plist */; }; 55B5E81B2F39158200852AA7 /* InternetStatusView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55B5E81A2F39158200852AA7 /* InternetStatusView.swift */; }; 55D865852F70982000A2EFF8 /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 55D865842F70982000A2EFF8 /* WidgetKit.framework */; }; 55D865872F70982000A2EFF8 /* SwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 55D865862F70982000A2EFF8 /* SwiftUI.framework */; }; @@ -185,6 +184,7 @@ AA0009042F22000900000001 /* ProfileConnectionCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0009002F22000900000001 /* ProfileConnectionCache.swift */; }; AA1B2C022F4E5A0100D1E2F3 /* TVGradientBackground.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA1B2C012F4E5A0100D1E2F3 /* TVGradientBackground.swift */; }; B1A2C3D42F3A000100000001 /* PeerDetailSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1A2C3D32F3A000100000001 /* PeerDetailSheet.swift */; }; + BB001A012F99000000000001 /* TroubleshootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB001A002F99000000000001 /* TroubleshootView.swift */; }; BB3D4E022F4E5A0200D1E2F3 /* TVPreSharedKeyButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB3D4E012F4E5A0200D1E2F3 /* TVPreSharedKeyButton.swift */; }; CC5F6A022F4E5A0300D1E2F3 /* TVQRCodeSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC5F6A012F4E5A0300D1E2F3 /* TVQRCodeSheet.swift */; }; E1A0B0012F5E000100000001 /* EmptyTabPlaceholder.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1A0B0002F5E000100000001 /* EmptyTabPlaceholder.swift */; }; @@ -347,9 +347,9 @@ 50E608232A79966600BAF09B /* AboutView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AboutView.swift; sourceTree = ""; }; 50E608252A79968500BAF09B /* AdvancedView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdvancedView.swift; sourceTree = ""; }; 53CB9305A9DC6CAD1895495A /* SharedUserDefaultsTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SharedUserDefaultsTests.swift; sourceTree = ""; }; + 5573F6ED2F9F523D00E63A73 /* GoogleService-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = "GoogleService-Info.plist"; sourceTree = ""; }; 558553F92FE34921004FB58D /* inter-variable.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; path = "inter-variable.ttf"; sourceTree = ""; }; 558553FA2FE34921004FB58D /* jetbrains-mono-variable.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; path = "jetbrains-mono-variable.ttf"; sourceTree = ""; }; - 5573F6ED2F9F523D00E63A73 /* GoogleService-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = "GoogleService-Info.plist"; sourceTree = ""; }; 55B5E81A2F39158200852AA7 /* InternetStatusView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InternetStatusView.swift; sourceTree = ""; }; 55D865832F70982000A2EFF8 /* NetBirdWidgetExtensionExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = NetBirdWidgetExtensionExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; 55D865842F70982000A2EFF8 /* WidgetKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WidgetKit.framework; path = System/Library/Frameworks/WidgetKit.framework; sourceTree = SDKROOT; }; @@ -361,7 +361,6 @@ 978FC46F2EEDF167002D0EB8 /* AppLogger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppLogger.swift; sourceTree = ""; }; 9CD257EF78F038560FF3112D /* VPNOnDemandView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VPNOnDemandView.swift; sourceTree = ""; }; A1B2C3D32F4A000100000001 /* VPNToggleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VPNToggleView.swift; sourceTree = ""; }; - BB001A002F99000000000001 /* TroubleshootView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TroubleshootView.swift; sourceTree = ""; }; A1B2C3D42EEDF500001A2B3C /* ConfigurationProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfigurationProvider.swift; sourceTree = ""; }; A1C3D5E72F000001001A2B3C /* WiFiOnDemandPolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WiFiOnDemandPolicy.swift; sourceTree = ""; }; A1C3D5E82F000002001A2B3C /* CellularOnDemandPolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CellularOnDemandPolicy.swift; sourceTree = ""; }; @@ -373,6 +372,7 @@ AA0009002F22000900000001 /* ProfileConnectionCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileConnectionCache.swift; sourceTree = ""; }; AA1B2C012F4E5A0100D1E2F3 /* TVGradientBackground.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TVGradientBackground.swift; sourceTree = ""; }; B1A2C3D32F3A000100000001 /* PeerDetailSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PeerDetailSheet.swift; sourceTree = ""; }; + BB001A002F99000000000001 /* TroubleshootView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TroubleshootView.swift; sourceTree = ""; }; BB3D4E012F4E5A0200D1E2F3 /* TVPreSharedKeyButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TVPreSharedKeyButton.swift; sourceTree = ""; }; C7A1CFF65CC44187912007EC /* iOSNetworksView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = iOSNetworksView.swift; sourceTree = ""; }; CC5F6A012F4E5A0300D1E2F3 /* TVQRCodeSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TVQRCodeSheet.swift; sourceTree = ""; }; @@ -563,7 +563,7 @@ 50A8910E2A792A15007C48FC = { isa = PBXGroup; children = ( - 5573F6ED2F9F523D00E63A73 /* GoogleService-Info.plist */, + 5573F6ED2F9F523D00E63A73 /* GoogleService-Info.plist */, 50D402932BD9143900D4AC5B /* NetBirdSDK.xcframework */, 50245A0A2A7AA9390034792B /* NetBird-Bridging-Header.h */, 50A891192A792A15007C48FC /* NetBird */, diff --git a/NetBird/Info.plist b/NetBird/Info.plist index 9db8a08..9040594 100644 --- a/NetBird/Info.plist +++ b/NetBird/Info.plist @@ -18,5 +18,15 @@ + + NSAppTransportSecurity + + NSAllowsLocalNetworking + + diff --git a/NetBird/Source/App/Views/Components/SafariView.swift b/NetBird/Source/App/Views/Components/SafariView.swift index 89e572c..dee09eb 100644 --- a/NetBird/Source/App/Views/Components/SafariView.swift +++ b/NetBird/Source/App/Views/Components/SafariView.swift @@ -2,9 +2,22 @@ // SafariView.swift // NetBird // -// iOS-only: Wraps ASWebAuthenticationSession for in-app web authentication. -// Uses ephemeral session so each login starts fresh (no shared cookies), -// which is required for multi-profile support. +// iOS-only: runs the interactive OAuth/SSO login in ASWebAuthenticationSession — +// the system authentication session, i.e. an external user-agent in the sense of +// RFC 8252. The page runs in Safari's own process, outside the app's reach, which +// is what keeps IdP-side features working: Google refuses OAuth in embedded +// webviews (disallowed_useragent), passkeys/WebAuthn and hardware security keys +// need a Safari-class agent, and enterprise policies (Okta, Entra conditional +// access) may block embedded webviews. +// +// The session is NOT ephemeral: it shares Safari's cookie jar, so the IdP's +// trusted-device cookie survives and a re-login does not re-prompt for the second +// factor. That single jar is shared by every profile, and the app deliberately +// does not try to partition it. Which account a login lands on is decided on the +// request instead: the SDK sends the profile's own account as an OIDC login_hint, +// and the adapter adds prompt=select_account when the login targets a different +// profile than the session last signed in as (see NetworkExtensionAdapter's +// authorizeURL(_:selectingAccount:)). // import SwiftUI @@ -13,15 +26,27 @@ import SwiftUI #if os(iOS) import AuthenticationServices +/// How the login browser ended. The session cannot report whether the login as a +/// whole succeeded — the OAuth code is only the first half, the SDK still has to +/// exchange it and register with the management server — so the caller resolves +/// the final outcome through the SDK's callbacks. +enum LoginBrowserOutcome { + /// The session captured the loopback redirect itself instead of letting the + /// browser follow it. The URL carries the authorization code and has been + /// replayed to the SDK's local server, so the flow continues. + case redirectCaptured + /// The browser was dismissed. Either the user cancelled, or they closed the + /// SDK's success page after the redirect already went through — the two are + /// indistinguishable here. + case closed + /// The session itself failed (could not present, or an OAuth error came back). + case failed(Error) +} + struct SafariView: UIViewControllerRepresentable { @Binding var isPresented: Bool let url: URL - /// Called when the web auth session ends (success, user cancel, or error). - /// Note: with the NetBird PKCE loopback flow the completion fires with a nil - /// callbackURL even on success — the loopback redirect is consumed by the Go HTTP - /// server, not the auth session — so the caller must determine success from the - /// SDK's login callback, not from this handler. - let didFinish: () -> Void + let didFinish: (LoginBrowserOutcome) -> Void func makeUIViewController(context: Context) -> UIViewController { let vc = UIViewController() @@ -34,6 +59,12 @@ struct SafariView: UIViewControllerRepresentable { func updateUIViewController(_ uiViewController: UIViewController, context: Context) {} + static func dismantleUIViewController(_ uiViewController: UIViewController, coordinator: Coordinator) { + // Programmatic teardown (e.g. the login was aborted elsewhere): the session + // presents its own window, which outlives this hosting VC unless cancelled. + coordinator.cancelSession() + } + func makeCoordinator() -> Coordinator { Coordinator(self) } @@ -47,27 +78,52 @@ struct SafariView: UIViewControllerRepresentable { } func startSession(from viewController: UIViewController) { - // The NetBird SDK uses a PKCE flow with an http://localhost redirect URI. - // ASWebAuthenticationSession intercepts that navigation before the browser - // follows it, so "http" works as a callback scheme in practice. - // A proper long-term fix requires the SDK to expose a custom-scheme - // redirect URI (e.g. "netbird://") for mobile OAuth flows. let completionHandler: ASWebAuthenticationSession.CompletionHandler = { [weak self] callbackURL, error in guard let self else { return } + let outcome: LoginBrowserOutcome + if let callbackURL { + // The session matched the loopback redirect and swallowed it, so + // the SDK's local HTTP server never saw the authorization code. + // Replay the request to hand the code over; without this the PKCE + // flow would wait until it expires. Harmless when the browser + // already delivered it — the SDK's server is gone by then and the + // request simply fails. + Self.replayToLoopback(callbackURL) + outcome = .redirectCaptured + } else if let error = error as? ASWebAuthenticationSessionError, + error.code == .canceledLogin { + outcome = .closed + } else if let error { + outcome = .failed(error) + } else { + outcome = .closed + } + DispatchQueue.main.async { - if let callbackURL = callbackURL { - print("Auth callback URL: \(callbackURL.absoluteString)") - } - if let error = error as? ASWebAuthenticationSessionError, - error.code == .canceledLogin { - print("User cancelled login") - } + self.session = nil self.parent.isPresented = false - self.parent.didFinish() + self.parent.didFinish(outcome) } } + // The SDK's PKCE flow uses a loopback redirect (RFC 8252 §7.3): the + // authorization code arrives at an HTTP server the SDK runs on + // 127.0.0.1. That server is the primary path and needs no interception — + // the browser simply follows the redirect to it, and this session then + // ends via .closed when the user dismisses the SDK's success page. + // + // A session must still declare a callback, and "http" is the closest + // match for that redirect. Should the session capture the navigation + // instead of letting the browser follow it, the completion handler + // replays the URL to the same local server, so the code still arrives. + // Neither path leaves the flow hanging: resolveLoginAfterBrowserClose + // probes the loopback listener and resolves the login either way. + // + // Apple intends this API for custom schemes or (iOS 17.4+) HTTPS + // host/path callbacks. Moving to either would mean the management server + // issuing a different redirect URI — a core/server change, not one the + // app can make on its own. let session: ASWebAuthenticationSession if #available(iOS 17.4, *) { session = ASWebAuthenticationSession( @@ -83,13 +139,42 @@ struct SafariView: UIViewControllerRepresentable { ) } - // Ephemeral = no shared cookies, fresh login every time - session.prefersEphemeralWebBrowserSession = true + // Never ephemeral. Sharing Safari's cookie jar is the whole point: the + // IdP's trusted-device cookie survives between logins, so the second + // factor is not re-prompted on every re-login — for every profile, not + // just one. Which account the session resolves to is decided by the + // login_hint in the authorize URL, so an empty jar buys no isolation + // here, it only throws the trusted-device state away. + session.prefersEphemeralWebBrowserSession = false session.presentationContextProvider = self self.session = session session.start() } + /// Dismisses the session UI. Safe to call after it already completed. + func cancelSession() { + session?.cancel() + session = nil + } + + /// Hands an intercepted authorization code to the SDK's loopback server. + private static func replayToLoopback(_ callbackURL: URL) { + var request = URLRequest(url: callbackURL) + request.timeoutInterval = 10 + URLSession.shared.dataTask(with: request) { _, _, error in + // Log the outcome without the URL — its query carries the live + // authorization code. A failure here is not fatal on its own: it also + // happens on the normal path, where the browser already delivered the + // code and the SDK's server is gone. It is the one signal that + // separates the two, so it is worth recording. + if let error { + AppLogger.shared.log("Login redirect replay to the loopback server failed: \(error.localizedDescription)") + } else { + AppLogger.shared.log("Login redirect replayed to the loopback server") + } + }.resume() + } + func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor { guard let keyWindow = UIApplication.shared.connectedScenes .compactMap({ $0 as? UIWindowScene }) diff --git a/NetBird/Source/App/Views/iOS/ProfilesListView.swift b/NetBird/Source/App/Views/iOS/ProfilesListView.swift index d548db8..c62279f 100644 --- a/NetBird/Source/App/Views/iOS/ProfilesListView.swift +++ b/NetBird/Source/App/Views/iOS/ProfilesListView.swift @@ -7,9 +7,17 @@ import SwiftUI #if os(iOS) +/// Per-profile display values shown under a profile's name. Resolved when the +/// list is loaded rather than while a row renders — see `loadProfiles()`. +private struct ProfileDisplayDetails { + let serverURL: String? + let account: String? +} + struct ProfilesListView: View { @EnvironmentObject var viewModel: ViewModel @State private var profiles: [Profile] = [] + @State private var profileDetails: [String: ProfileDisplayDetails] = [:] @State private var showAddSheet = false @State private var showSwitchAlert = false @State private var showRemoveAlert = false @@ -35,11 +43,7 @@ struct ProfilesListView: View { Text(active.name) .font(.body.bold()) .foregroundColor(Color("TextPrimary")) - if let url = ProfileManager.shared.managementURL(for: active.name) { - Text(url) - .font(.footnote) - .foregroundColor(Color("TextSecondary")) - } + profileSubtitle(for: active) } Spacer() Text("Active") @@ -78,11 +82,7 @@ struct ProfilesListView: View { Text(profile.name) .font(.body) .foregroundColor(Color("TextPrimary")) - if let url = ProfileManager.shared.managementURL(for: profile.name) { - Text(url) - .font(.footnote) - .foregroundColor(Color("TextSecondary")) - } + profileSubtitle(for: profile) } } .swipeActions(edge: .trailing, allowsFullSwipe: false) { @@ -159,10 +159,47 @@ struct ProfilesListView: View { } } + // MARK: - Rows + + /// Server and account lines under a profile's name. The account is the one the + /// profile last signed in with — it is also what goes out as the login_hint on + /// the next login, so showing it makes visible which account a re-login returns + /// to. A profile that never completed an SSO login, or was logged out, has none. + /// + /// Reads only what `loadProfiles()` already resolved: both lookups touch the + /// filesystem, and a body may be evaluated any number of times. + @ViewBuilder + private func profileSubtitle(for profile: Profile) -> some View { + let details = profileDetails[profile.name] + if let url = details?.serverURL { + Text(url) + .font(.footnote) + .foregroundColor(Color("TextSecondary")) + } + if let email = details?.account { + Text(email) + .font(.footnote) + .foregroundColor(Color("TextSecondary")) + .lineLimit(1) + .truncationMode(.middle) + } + } + // MARK: - Actions private func loadProfiles() { - profiles = ProfileManager.shared.listProfiles() + let loaded = ProfileManager.shared.listProfiles() + // Resolved here, not while a row renders: managementURL(for:) reads the + // profile's config and writes the resolved URL back to the server-URL file + // and the connection cache, and accountEmail(for:) goes through the SDK to + // disk. Doing either inside `body` turns every redraw into file I/O. + profileDetails = Dictionary(uniqueKeysWithValues: loaded.map { profile in + (profile.name, ProfileDisplayDetails( + serverURL: ProfileManager.shared.managementURL(for: profile.name), + account: ProfileManager.shared.accountEmail(for: profile.name) + )) + }) + profiles = loaded } private func switchToProfile(_ profile: Profile) { diff --git a/NetBird/Source/App/Views/iOS/iOSConnectionView.swift b/NetBird/Source/App/Views/iOS/iOSConnectionView.swift index 4195e04..6755ac5 100644 --- a/NetBird/Source/App/Views/iOS/iOSConnectionView.swift +++ b/NetBird/Source/App/Views/iOS/iOSConnectionView.swift @@ -147,7 +147,8 @@ struct iOSConnectionView: View { } } - // Safari login view — shown regardless of statusDetailsValid + // System auth session — started regardless of statusDetailsValid. It + // presents its own modal window, so this view only hosts the launcher. if viewModel.networkExtensionAdapter.showBrowser, let loginURLString = viewModel.networkExtensionAdapter.loginURL, let loginURL = URL(string: loginURLString) @@ -155,25 +156,33 @@ struct iOSConnectionView: View { SafariView( isPresented: $viewModel.networkExtensionAdapter.showBrowser, url: loginURL, - didFinish: { - if viewModel.networkExtensionAdapter.loginSucceeded { - print("Finish login") - // The SDK just completed the management login, so the extension - // can skip its own needs-login check (one Login RPC) when it starts. - viewModel.networkExtensionAdapter.startVPNConnection(loginVerified: true) - } else { - // User closed the browser without completing login. Do NOT start - // the VPN — that would launch the extension, trip its needs-login - // path, and pop a spurious "Login required" alert/notification. - print("Login cancelled by user") - viewModel.cancelPendingLogin() - } - } + didFinish: loginBrowserDidFinish ) + } else if viewModel.networkExtensionAdapter.showBrowser { + // A login was started but its authorize URL is missing or unparsable, + // so no browser can be presented. Nothing would ever report an + // outcome, leaving the SDK flow pending until it expires — cancel it + // here instead. + Color.clear.onAppear { + // The URL never goes to the log: it carries the OAuth state, the + // redirect target and the login_hint — the user's email address. + // Which of the two failure modes it was is the diagnostic part. + let reason = viewModel.networkExtensionAdapter.loginURL == nil ? "missing" : "unparsable" + AppLogger.shared.log("Login browser: \(reason) authorize URL — cancelling") + viewModel.cancelPendingLogin() + } } } .navigationBarTitleDisplayMode(.inline) .navigationBarHidden(true) + .alert("Login failed", isPresented: Binding( + get: { viewModel.networkExtensionAdapter.loginErrorMessage != nil }, + set: { if !$0 { viewModel.networkExtensionAdapter.loginErrorMessage = nil } } + )) { + Button("OK") { viewModel.networkExtensionAdapter.loginErrorMessage = nil } + } message: { + Text(viewModel.networkExtensionAdapter.loginErrorMessage ?? "") + } .onAppear { // Returning to this tab doesn't go through applyExtensionStatus, so refresh the // network map here to keep the exit node selector current. Only while connected: @@ -185,6 +194,46 @@ struct iOSConnectionView: View { } } + /// Resolves what the login browser's end means for the VPN. + private func loginBrowserDidFinish(_ outcome: LoginBrowserOutcome) { + let adapter = viewModel.networkExtensionAdapter + + switch outcome { + case .failed(let error): + print("Login browser failed: \(error.localizedDescription)") + AppLogger.shared.log("Login browser failed: \(error.localizedDescription)") + viewModel.cancelPendingLogin() + + case .redirectCaptured: + // The authorization code was handed to the SDK; the rest of the login + // runs there. The adapter starts the VPN once it reports success. + print("Login redirect captured - waiting for the SDK to finish") + adapter.resolveLoginAfterBrowserClose { + print("Login did not complete after redirect - resetting") + viewModel.cancelPendingLogin() + } + + case .closed: + if adapter.loginSucceeded { + print("Finish login") + // The SDK just completed the management login, so the extension can + // skip its own needs-login check (one Login RPC) when it starts. + adapter.startVPNConnection(loginVerified: true) + return + } + // Ambiguous: the user may have cancelled, or closed the SDK's success + // page while registration was still running. Never start the VPN here — + // that would launch the extension, trip its needs-login path and pop a + // spurious "Login required" alert. Let the adapter decide, and only + // reset the UI if it concludes the login is not in flight. + print("Login browser closed without a reported success - resolving") + adapter.resolveLoginAfterBrowserClose { + print("Login cancelled or failed - resetting") + viewModel.cancelPendingLogin() + } + } + } + @ViewBuilder private func addressRow(value: String, copied: Binding) -> some View { HStack { diff --git a/NetbirdKit/GlobalConstants.swift b/NetbirdKit/GlobalConstants.swift index 189c380..3871362 100644 --- a/NetbirdKit/GlobalConstants.swift +++ b/NetbirdKit/GlobalConstants.swift @@ -41,6 +41,14 @@ struct GlobalConstants { static let stateFileName = "state.json" static let serverURLFileName = "netbird_server_url" + // Profile whose account the login browser's shared cookie jar last signed in + // with. Only decides whether the next login asks the IdP for an account chooser + // (prompt=select_account) — the account a profile belongs to is bound by the + // login_hint the SDK sends, not by this. An empty string means "an account no + // profile may silently reuse": set on logout, so signing back in cannot land on + // the account just left. + static let keyLastBrowserLoginProfile = "netbird.lastBrowserLoginProfile" + // Local notification identifiers static let notificationLoginRequired = "netbird.login.required" } diff --git a/NetbirdKit/NetworkExtensionAdapter.swift b/NetbirdKit/NetworkExtensionAdapter.swift index c676e7e..3db218d 100644 --- a/NetbirdKit/NetworkExtensionAdapter.swift +++ b/NetbirdKit/NetworkExtensionAdapter.swift @@ -9,6 +9,7 @@ import Foundation import NetworkExtension import SwiftUI import Combine +import Network import NetBirdSDK import os @@ -76,13 +77,22 @@ public class NetworkExtensionAdapter: ObservableObject { @Published var loginURL: String? #if os(iOS) private var pendingAuth: NetBirdSDKAuth? - /// Set to true by the SDK's onLoginSuccess callback (which fires once the Go PKCE - /// localhost server receives the OAuth callback). The browser-finished handler reads - /// this to tell a genuine login from the user dismissing the browser: the - /// ASWebAuthenticationSession completion fires with a nil callbackURL even on success - /// (the loopback redirect is consumed by the Go HTTP server, not the auth session), - /// so the SafariView callback alone cannot distinguish success from cancellation. + /// Set to true by the SDK's onLoginSuccess callback, which fires only once the + /// whole flow is done: authorization code exchanged AND the peer registered with + /// the management server. The browser cannot report this — its completion looks + /// the same whether the user cancelled or closed the SDK's success page — so + /// every "did the login work" decision reads this flag. public private(set) var loginSucceeded = false + /// Reason the last login failed, surfaced to the user. Nil when there is nothing + /// to report. + @Published public var loginErrorMessage: String? + /// Incremented on every performLogin entry. Deferred work armed for one attempt + /// captures the value and compares before acting, so a stale timer can never + /// abort a newer attempt. + public private(set) var loginAttemptToken = 0 + /// Authorization URL of the in-flight login, used to locate the SDK's loopback + /// listener when deciding whether a closed browser means "cancelled". + private var pendingAuthorizeURL: String? #endif @Published var userCode: String? @@ -490,12 +500,55 @@ public class NetworkExtensionAdapter: ObservableObject { } let activeManagementURL = resolvedURL ?? "" logger.info("performLogin: using management URL '\(activeManagementURL, privacy: .public)' for profile '\(activeProfile, privacy: .public)'") + // Every profile logs in through the same persistent browser session, which is + // what keeps the IdP's SSO session and its trusted-device cookie alive so a + // re-login is not asked for the second factor again. Which account that + // session lands on is not left to the shared cookie jar: the SDK sends the + // profile's own account as an OIDC login_hint (see the Go binding's + // profile_state.go), so a re-login targets the account the profile already + // belongs to, and a profile with no stored account — fresh, or logged out — + // deliberately leaves the choice to the IdP, which is how accounts change. + // + // A hint is advisory, though: an IdP holding a live session for another + // account may sign in with that session instead, which ends with the peer's + // key and the token belonging to different accounts ("peer is already + // registered by a different User"). Two cases have to ask the IdP for an + // account chooser rather than let the session resolve itself: + // + // - the profile has no account bound yet. Either it never completed an SSO + // login, or it was logged out, or it last logged in before this app + // version existed — in all three the app has nothing to steer with and no + // way to tell whose account the session is holding. It costs one chooser + // screen, once: the login binds the account and every re-login after it + // goes out with the hint and stays silent. + // - the session last signed in as a different profile, so its account is + // known to be the wrong one for this login. + // + // See authorizeURL(_:promptingForAccount:) for why that ends up as + // prompt=login rather than the friendlier prompt=select_account. + let boundAccount = ProfileManager.shared.accountEmail(for: activeProfile) + let promptForAccount = boundAccount == nil || Preferences.browserSessionHoldsAnotherProfile(activeProfile) + if promptForAccount { + let reason = boundAccount == nil ? "no account bound to the profile" : "the browser session last signed in as another profile" + logger.info("performLogin: '\(activeProfile, privacy: .public)' asks the IdP to re-decide the account — \(reason, privacy: .public)") + AppLogger.shared.log("performLogin: '\(activeProfile)' asks the IdP to re-decide the account (\(reason))") + } else { + AppLogger.shared.log("performLogin: '\(activeProfile)' reuses the browser session with a login_hint") + } if let configPath = Preferences.configFile(), !configPath.isEmpty, let auth = NetBirdSDKNewAuth(configPath, activeManagementURL, nil) { + // A stale flow from an abandoned attempt would keep its loopback port + // bound and its WaitToken goroutine alive — stop it first. + self.pendingAuth?.stop() self.pendingAuth = auth + self.loginAttemptToken += 1 self.loginSucceeded = false let urlOpener = MainAppLoginURLOpener() let errListener = MainAppLoginErrListener() + // Set once the browser actually opened. Gates the ownership-conflict + // self-heal below: an error before the browser phase falls through to the + // IPC fallback, and retrying concurrently with it would race two flows. + var browserPhaseStarted = false let receivedURL: String? = await withCheckedContinuation { continuation in var resumed = false @@ -512,8 +565,14 @@ public class NetworkExtensionAdapter: ObservableObject { // the extension, trips its needsLogin path, and pops the auth alert // in parallel with this browser login. Ordering them guarantees the // await caller sees showBrowser == true. + let rewritten = Self.authorizeURL(url, promptingForAccount: promptForAccount) + let browserURL = rewritten.url + AppLogger.shared.log("performLogin: authorize URL account prompt — \(rewritten.outcome.rawValue)") DispatchQueue.main.async { - self?.loginURL = url + browserPhaseStarted = true + self?.loginErrorMessage = nil + self?.pendingAuthorizeURL = browserURL + self?.loginURL = browserURL self?.showBrowser = true resume(url) } @@ -534,20 +593,72 @@ public class NetworkExtensionAdapter: ObservableObject { ProfileManager.shared.saveServerURL(activeManagementURL, for: activeProfile) Preferences.saveManagementURL(activeManagementURL) } + // The account this login ran under is recorded by the SDK itself, + // keyed by the config path it was handed, so the next login for + // this profile can go out with it as the login_hint. What the SDK + // cannot see is the browser session it went through, so record + // here which profile that session now holds — the next login of a + // different profile uses it to ask for the account chooser. + Preferences.saveLastBrowserLoginProfile(activeProfile) + AppLogger.shared.log("performLogin: SDK login succeeded for '\(activeProfile)'") // onSuccess runs on a background goroutine. Mark success on the main // queue so the browser-finished handler (also main-queue) reliably - // observes it and starts the VPN instead of treating the browser - // dismissal as a cancellation. + // observes it. DispatchQueue.main.async { - self?.loginSucceeded = true - self?.pendingAuth = nil + guard let self else { return } + // Success is delivered twice (urlOpener.onLoginSuccess and the + // result listener); act on the first only. + guard !self.loginSucceeded else { return } + self.logger.info("performLogin: SDK login succeeded") + self.loginSucceeded = true + self.pendingAuth = nil + self.pendingAuthorizeURL = nil + // If the browser is already gone, the view's completion handler + // deferred the decision to us — the login only finished now, so + // start the VPN here. While it is still open, the view starts it + // when the user dismisses the success page. + if !self.showBrowser { + self.logger.info("performLogin: login completed after browser closed - starting VPN") + // The management login just completed here, so the extension + // can skip its own needs-login check (one Login RPC). + self.startVPNConnection(loginVerified: true) + } } } errListener.onSuccessCallback = { urlOpener.onSuccess?() } - errListener.onErrorCallback = { [weak self] _ in - // onError runs on a background goroutine; mutate pendingAuth on the - // main queue to stay consistent with onSuccess and cancelLogin(). - DispatchQueue.main.async { self?.pendingAuth = nil } + errListener.onErrorCallback = { [weak self] error in + // Surface the reason: a login that dies after the browser phase + // (failed token exchange or management registration) is otherwise + // indistinguishable from "nothing happened". + let message = error?.localizedDescription ?? "unknown login error" + AppLogger.shared.log("performLogin: SDK login failed: \(message)") + // onError runs on a background goroutine; mutate state on the main + // queue to stay consistent with onSuccess and cancelLogin(). + DispatchQueue.main.async { + guard let self else { return } + self.logger.error("performLogin: SDK login failed: \(message, privacy: .public)") + self.pendingAuth = nil + self.pendingAuthorizeURL = nil + guard browserPhaseStarted else { return } + // "peer is already registered by a different User or a Setup + // Key" means the account that signed in does not own this + // profile's peer. Report it rather than "repairing" it: the + // app cannot tell a stale local key from a login under the + // wrong account, and deleting the profile's identity to fix + // the latter destroys a working registration and can + // re-register the peer under the wrong account. Removing an + // identity stays an explicit user action — Profiles → Log out. + if message.contains("registered by a different User") { + self.loginErrorMessage = """ + This profile belongs to a different NetBird account. \ + Sign in with the account that owns it, or log the \ + profile out (Profiles → Log out) to register it again. + """ + } else { + self.loginErrorMessage = message + } + self.showBrowser = false + } resume(nil) } // Pass the device name explicitly. The plain login() path uses an empty @@ -581,10 +692,74 @@ public class NetworkExtensionAdapter: ObservableObject { logger.error("performLogin: no login URL received from extension, aborting") return } + #if os(iOS) + // Same account-chooser policy as the main-app path. This path cannot observe + // login success, so it never records which profile the session ended up on — + // which also means the profile never gets an account bound and every login + // here asks, rather than silently resolving through the session. + let fallbackProfile = ProfileManager.shared.getActiveProfileName() + let rewritten = Self.authorizeURL( + url, + promptingForAccount: ProfileManager.shared.accountEmail(for: fallbackProfile) == nil + || Preferences.browserSessionHoldsAnotherProfile(fallbackProfile) + ) + AppLogger.shared.log("performLogin: authorize URL account prompt — \(rewritten.outcome.rawValue)") + self.pendingAuthorizeURL = rewritten.url + self.loginURL = rewritten.url + #else self.loginURL = url + #endif self.showBrowser = true } + #if os(iOS) + /// What asking the IdP to re-decide the account did to an authorize URL. Reported + /// so the log says what actually reached the IdP, not merely what was intended — + /// a request that was skipped looks identical from the outside otherwise. + enum AccountPromptOutcome: String { + /// `prompt=login` was added. + case added + /// The flow already asked for a prompt of its own; it is left alone. + case alreadyPrompting + /// The URL could not be parsed, so it goes out untouched. + case urlNotParsable + /// This login may resolve through the existing session. + case notRequested + } + + /// Asks the IdP to re-decide which account signs in, for logins that must not be + /// resolved by whatever session the browser already holds. + /// + /// The value is `login`, not `select_account`. `select_account` is the friendlier + /// request — pick an account, no re-authentication — but only Google, Microsoft + /// and Okta implement it; Auth0 and Zitadel ignore it and sign in with the session + /// they already have, which is exactly the failure this is meant to prevent. + /// `prompt=login` is the one value every OIDC provider honours. It costs a + /// password on an account switch, but not the second factor: it re-authenticates + /// the user, while the trusted-device cookie that gates 2FA stays in the jar. + /// + /// A `prompt` the flow itself put there (the management server drives this through + /// its login flag) wins — overriding a server-chosen prompt is not this layer's + /// call. + static func authorizeURL( + _ urlString: String, + promptingForAccount: Bool + ) -> (url: String, outcome: AccountPromptOutcome) { + guard promptingForAccount else { return (urlString, .notRequested) } + guard var components = URLComponents(string: urlString) else { + return (urlString, .urlNotParsable) + } + var items = components.queryItems ?? [] + guard !items.contains(where: { $0.name == "prompt" }) else { + return (urlString, .alreadyPrompting) + } + items.append(URLQueryItem(name: "prompt", value: "login")) + components.queryItems = items + guard let rewritten = components.string else { return (urlString, .urlNotParsable) } + return (rewritten, .added) + } + #endif + #if os(iOS) /// Aborts an in-progress interactive login (e.g. the user dismissed the OAuth /// browser without completing it). Stopping the SDK auth cancels its context, @@ -595,9 +770,118 @@ public class NetworkExtensionAdapter: ObservableObject { logger.info("cancelLogin: aborting in-progress login") pendingAuth?.stop() pendingAuth = nil + pendingAuthorizeURL = nil loginSucceeded = false showBrowser = false } + + /// Decides what a dismissed login browser means and calls `abort` only when the + /// login is definitely not in flight. + /// + /// The system auth session reports the same "cancelled" completion whether the + /// user backed out of the IdP page or closed the SDK's success page after the + /// redirect already went through, so the dismissal alone cannot be trusted. The + /// SDK's loopback listener settles it: it stays bound while the flow is still + /// waiting for the authorization code and goes away once the code arrives. A + /// listener that is still up on two probes means nothing was delivered — a real + /// cancel. Anything else defers to the SDK, with a bounded fallback so a login + /// that dies silently cannot leave the UI stuck on "Connecting…". + public func resolveLoginAfterBrowserClose(abort: @escaping () -> Void) { + let token = loginAttemptToken + // `abort` must never fire for an attempt other than the one being resolved. + let abortIfStillCurrent: () -> Void = { [weak self] in + guard let self, self.loginAttemptToken == token, + !self.loginSucceeded, !self.showBrowser else { return } + abort() + } + + guard let endpoint = pendingAuthorizeURL.flatMap(Self.loopbackEndpoint(fromAuthorizeURL:)) else { + logger.info("resolveLoginAfterBrowserClose: no loopback endpoint known, deferring to SDK result") + DispatchQueue.main.asyncAfter(deadline: .now() + Self.loginResolutionTimeout, execute: abortIfStillCurrent) + return + } + + Self.probeListener(host: endpoint.host, port: endpoint.port) { [weak self] listening in + guard let self else { return } + guard listening else { + self.logger.info("resolveLoginAfterBrowserClose: loopback listener gone — code delivered, waiting for the SDK") + DispatchQueue.main.asyncAfter(deadline: .now() + Self.loginResolutionTimeout, execute: abortIfStillCurrent) + return + } + // Still listening: either nothing was delivered, or the code arrived and + // the token exchange is running with the listener briefly still up. + // Re-probe once before treating it as a cancel so a live exchange is + // never killed. + DispatchQueue.main.asyncAfter(deadline: .now() + Self.loopbackRecheckDelay) { + Self.probeListener(host: endpoint.host, port: endpoint.port) { stillListening in + DispatchQueue.main.async { + if stillListening { + self.logger.info("resolveLoginAfterBrowserClose: loopback still waiting for the code — treating as cancelled") + abortIfStillCurrent() + } else { + self.logger.info("resolveLoginAfterBrowserClose: code delivered late, waiting for the SDK") + DispatchQueue.main.asyncAfter(deadline: .now() + Self.loginResolutionTimeout, execute: abortIfStillCurrent) + } + } + } + } + } + } + + /// How long to wait for the SDK's verdict once the code is known to be delivered. + /// Covers a first-time peer registration, which can outlast the browser session. + private static let loginResolutionTimeout: TimeInterval = 20 + /// Gap between loopback probes, long enough to cover a token exchange. + private static let loopbackRecheckDelay: TimeInterval = 3 + + /// Extracts the loopback host/port the SDK told the IdP to redirect to. + static func loopbackEndpoint(fromAuthorizeURL urlString: String) -> (host: String, port: UInt16)? { + guard let components = URLComponents(string: urlString), + let redirect = components.queryItems?.first(where: { $0.name == "redirect_uri" })?.value, + let redirectComponents = URLComponents(string: redirect), + let host = redirectComponents.host, + let port = redirectComponents.port, + let port16 = UInt16(exactly: port) + else { return nil } + return (host, port16) + } + + /// Reports whether something accepts TCP connections at host:port. + private static func probeListener(host: String, port: UInt16, completion: @escaping (Bool) -> Void) { + guard let nwPort = NWEndpoint.Port(rawValue: port) else { + completion(false) + return + } + let connection = NWConnection(host: NWEndpoint.Host(host), port: nwPort, using: .tcp) + // Both the connection's state updates and the timeout below run here. A + // serial queue is what makes `settled` safe: on a concurrent queue the + // watchdog could run alongside a state update, and the check-then-set would + // let both through — cancelling twice and reporting the result twice. + let queue = DispatchQueue(label: "io.netbird.loopback-probe") + var settled = false + let settle: (Bool) -> Void = { listening in + guard !settled else { return } + settled = true + connection.cancel() + completion(listening) + } + connection.stateUpdateHandler = { state in + switch state { + case .ready: + settle(true) + case .failed, .cancelled: + settle(false) + case .waiting: + // Connection refused surfaces as .waiting with a retry — for loopback + // that means nothing is bound. + settle(false) + default: + break + } + } + connection.start(queue: queue) + queue.asyncAfter(deadline: .now() + 2) { settle(false) } + } #endif /// Starts the tunnel. diff --git a/NetbirdKit/Preferences.swift b/NetbirdKit/Preferences.swift index c9efdd6..99b496e 100644 --- a/NetbirdKit/Preferences.swift +++ b/NetbirdKit/Preferences.swift @@ -178,6 +178,46 @@ class Preferences { return sharedUserDefaults()?.string(forKey: managementURLKey) } + // MARK: - Login Browser Account Tracking + // + // The login browser has one cookie jar shared by every profile. login_hint tells + // the IdP which account a profile wants, but a hint is advisory — an IdP with a + // live session for another account signs in with that session instead, which is + // how a profile ends up holding a peer key and a token from two different + // accounts. Recording which profile last completed a login through that jar lets + // the next login of a different profile ask the IdP to re-decide, instead of + // hoping the hint is honoured. Drift only ever costs one extra prompt, so nothing + // depends on this being exact. + + /// Profile whose account the shared browser session last signed in with. Nil when + /// no login has completed yet; "" when the session holds an account no profile may + /// silently reuse (see `requireAccountSelectionOnNextLogin`). + static func loadLastBrowserLoginProfile() -> String? { + return sharedUserDefaults()?.string(forKey: GlobalConstants.keyLastBrowserLoginProfile) + } + + /// Records the profile a completed login signed in as. + static func saveLastBrowserLoginProfile(_ name: String) { + sharedUserDefaults()?.set(name, forKey: GlobalConstants.keyLastBrowserLoginProfile) + } + + /// Makes the next login — of any profile — ask the IdP to re-decide the account. + /// Called on logout and on removing a profile: the browser session still holds the + /// account that was just left, and no profile should be signed back into it + /// silently. Stores "" because no profile can be named that, so the "profile + /// changed" test below matches every profile. + static func requireAccountSelectionOnNextLogin() { + sharedUserDefaults()?.set("", forKey: GlobalConstants.keyLastBrowserLoginProfile) + } + + /// Whether a login for `profile` must make the IdP re-decide which account signs + /// in: the shared session last signed in as a different profile, or as an account + /// that was logged out. The first login on an install has nothing to disambiguate. + static func browserSessionHoldsAnotherProfile(_ profile: String) -> Bool { + guard let last = loadLastBrowserLoginProfile() else { return false } + return last != profile + } + /// Restore config from UserDefaults to the config file path. /// iOS only - needed because the Go SDK reads from the file path. #if os(iOS) diff --git a/NetbirdKit/ProfileManager.swift b/NetbirdKit/ProfileManager.swift index 1c1d6d6..4e39501 100644 --- a/NetbirdKit/ProfileManager.swift +++ b/NetbirdKit/ProfileManager.swift @@ -8,6 +8,7 @@ // import Foundation +import NetBirdSDK // MARK: - Profile Model @@ -165,8 +166,16 @@ class ProfileManager { try writeMeta(meta) } + // The profile's account file lives inside `dir`, so removing the directory + // already takes the stored login_hint with it — a future profile with the + // same name starts with no account bound to it. try fileManager.removeItem(atPath: dir) ProfileConnectionCache().remove(for: name) + // The browser session may still hold this profile's account, and nothing + // names it any more — the next login has to ask which account to use. + if Preferences.loadLastBrowserLoginProfile() == name { + Preferences.requireAccountSelectionOnNextLogin() + } } /// Clears authentication data for a profile by removing its config and state files. @@ -186,6 +195,15 @@ class ProfileManager { } cache.clearConnectionData(for: name) + // Logging out must actually log out. While the account email is on disk it + // goes out as the login_hint, which would steer the next login straight back + // into the account just logged out of — dropping it hands the account choice + // back to the IdP, which is how a profile changes accounts. The browser + // session still holds that account, though, so also require the next login to + // ask which account to use rather than resolving silently through it. + clearAccountEmail(for: name) + Preferences.requireAccountSelectionOnNextLogin() + if fileManager.fileExists(atPath: statePath) { try fileManager.removeItem(atPath: statePath) } @@ -252,6 +270,34 @@ class ProfileManager { return ProfileConnectionCache().managementURL(for: profile) } + // MARK: - Account Binding + // + // The SDK records the account a profile logged in with next to that profile's + // config file, and reads it back as the OIDC login_hint on every later login, + // so a re-login returns to the same account without a fresh password + OTP + // prompt. The app owns the directory layout, so it goes through the SDK by + // config path rather than duplicating the file naming here. + + /// Account the profile last logged in with, or nil if it never completed an SSO + /// login or was logged out. Display-only — an unresolvable path reads as nil. + func accountEmail(for profile: String) -> String? { + guard let cfgPath = configPath(for: profile) else { return nil } + let email = NetBirdSDKProfileAccountEmail(cfgPath) + return email.isEmpty ? nil : email + } + + /// Forgets the account bound to a profile, so its next login carries no + /// login_hint and the IdP asks which account to use. + func clearAccountEmail(for profile: String) { + guard let cfgPath = configPath(for: profile) else { return } + var err: NSError? + NetBirdSDKClearProfileAccountEmail(cfgPath, &err) + if let err { + // Not fatal: a stale hint costs an account switch, not the logout itself. + AppLogger.shared.log("ProfileManager: failed to clear account email for '\(profile)': \(err.localizedDescription)") + } + } + /// Saves the management URL to a dedicated file inside the profile directory. /// This file is NOT deleted by logoutProfile(), so it survives logout. func saveServerURL(_ url: String, for profile: String) { diff --git a/netbird-core b/netbird-core index 5584f8e..0d92607 160000 --- a/netbird-core +++ b/netbird-core @@ -1 +1 @@ -Subproject commit 5584f8ef0a8dbd7d1db49655914d7c2c39b431cf +Subproject commit 0d92607d30cea3a36914cf5b0b191c9406361a08