Skip to content

fix(ios): repeated 2FA prompt during SSO re-login (iOS) - #175

Open
evgeniyChepelev wants to merge 5 commits into
netbirdio:mainfrom
evgeniyChepelev:fix/ios-per-profile-login-browser
Open

fix(ios): repeated 2FA prompt during SSO re-login (iOS)#175
evgeniyChepelev wants to merge 5 commits into
netbirdio:mainfrom
evgeniyChepelev:fix/ios-per-profile-login-browser

Conversation

@evgeniyChepelev

@evgeniyChepelev evgeniyChepelev commented Jul 27, 2026

Copy link
Copy Markdown
Collaborator

Description

Repeated 2FA prompt during SSO re-login (iOS)

Problem

Since the multi-profiles change, the SSO login ran in an ephemeral
ASWebAuthenticationSession (no cookies kept), so the IdP's
trusted-device cookie was lost after every session and users had to
re-enter the OTP on every re-login — unlike Android, which preserves the
trusted-device state. On top of that, several race conditions in the
login flow could silently kill a login that had actually succeeded in
the browser.

What was done

One login browser for all profiles, with per-profile persistent cookies

  • The login now runs in a WKWebView (ProfileLoginWebView) presented
    as a sheet. Each profile gets its own persistent
    WKWebsiteDataStore(forIdentifier:) keyed by a stable per-profile
    UUID (iOS 17+), so:
    • the IdP's trusted-device 2FA cookie survives re-logins → the OTP is
      asked only on the first login of a profile;
    • profiles are fully isolated — one profile can never see or reuse
      another profile's IdP session.
  • On pre-iOS 17 the store falls back to an isolated non-persistent one
    (same isolation, OTP on each login).
  • Removing a profile deletes its cookie store, so a future profile with
    the same name inherits nothing.

Login-flow correctness fixes

  • The browser closes only after the loopback response has finished
    loading (token exchange done). Closing on a timer could cancel the
    in-flight localhost:53000 request, which cancelled the token
    exchange itself and silently killed the login.
  • Browser auto-close is no longer treated as a user cancel. If the SDK
    is still finishing the management login when the sheet closes (typical
    for a first-time profile registration), the adapter starts the VPN
    itself once the SDK reports success. Explicit Cancel and swipe-down
    still abort the login properly.
  • SDK login errors are now logged (os_log + swift-log.log) instead of
    being silently discarded.
  • Self-heal for peer is already registered by a different User or a Setup Key: the profile's local identity (config + state, server URL
    preserved) is reset and the login retried once with a fresh WireGuard
    key, registering a new peer under the correct account.

How to test

Prereqs: an IdP with 2FA where "trust this device" is offered; iOS 17+
device for the persistence scenarios.

  1. Re-login without OTP (main fix): log in to a profile entering
    password + OTP, mark the device as trusted, connect. Log out (or wait
    for session expiry) and log in again → the browser flashes and closes
    itself, no password/OTP prompt, VPN connects.
  2. Second profile, first login: create another profile, connect →
    full login (password + OTP). The sheet closes on its own and the VPN
    connects even though registration finishes after the sheet closed.
  3. Second profile, re-login: expire/logout and reconnect → no OTP,
    same as scenario 1. Each profile keeps its own trusted-device state.
  4. Profile isolation: log profiles into different accounts and
    switch between them — a login for profile B must never auto-complete
    with profile A's session.
  5. Cancel paths: close the sheet via the Cancel button and via
    swipe-down before completing the login → VPN must not start, no
    "Login required" alert, connect button returns to Disconnected.
  6. Ownership-conflict recovery: on a profile that previously failed
    with "peer is already registered by a different User or a Setup Key",
    connect and log in → the sheet briefly reopens (silent SSO retry) and
    the VPN connects; a new peer appears in the dashboard.

Known limitations: IdPs that block OAuth in embedded web views (notably
Google's disallowed_useragent policy) will not work in this browser;
after updating, the first re-login of an existing profile asks for the
OTP once (cookies from the previous browser cannot be migrated).


View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled.

Summary by CodeRabbit

  • New Features

    • Improved iOS login handling with clear outcomes for successful redirects, cancellations, and failures.
    • Preserved browser sessions for returning profiles while supporting fresh sessions when switching accounts or after logout.
    • Added clearer login error messages and safeguards for interrupted or delayed authentication attempts.
  • Bug Fixes

    • Improved recovery from identity conflicts and stale login sessions.
    • Prevented removed or logged-out profiles from being silently reauthenticated.
    • Improved handling of local authentication redirects and VPN startup after browser dismissal.

@coderabbitai

coderabbitai Bot commented Jul 27, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: aa03fa23-7536-4949-bf54-b217f6031c0a

📥 Commits

Reviewing files that changed from the base of the PR and between 524f85a and 8f7b771.

📒 Files selected for processing (2)
  • NetBird/Source/App/Views/Components/SafariView.swift
  • NetbirdKit/NetworkExtensionAdapter.swift
🚧 Files skipped from review as they are similar to previous changes (2)
  • NetBird/Source/App/Views/Components/SafariView.swift
  • NetbirdKit/NetworkExtensionAdapter.swift

📝 Walkthrough

Walkthrough

The iOS login flow now reports typed browser outcomes, replays captured loopback redirects, tracks profile ownership, selects persistent or ephemeral sessions, and coordinates SDK completion, cancellation, and VPN startup.

Changes

Profile login flow

Layer / File(s) Summary
Profile authentication state
NetbirdKit/GlobalConstants.swift, NetbirdKit/Preferences.swift, NetbirdKit/ProfileManager.swift
Stores the last authenticated profile and fresh-session marker. Logout and profile removal update both values.
Browser outcome and loopback handling
NetBird/Source/App/Views/Components/SafariView.swift, NetBird/Source/App/Views/iOS/iOSConnectionView.swift, NetBird/Info.plist
Reports redirect, close, and failure outcomes. Replays loopback redirects, supports configurable session persistence, cancels sessions during teardown, and enables local networking.
SDK login orchestration
NetbirdKit/NetworkExtensionAdapter.swift, NetBird.xcodeproj/project.pbxproj
Selects the browser session policy, tracks login attempts, handles SDK callbacks and IPC fallback, probes loopback state after browser closure, and updates VPN state. The project file also registers TroubleshootView.swift and reorders resource references.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant iOSConnectionView
  participant SafariView
  participant NetworkExtensionAdapter
  participant NetBirdSDKNewAuth
  participant LoopbackListener
  iOSConnectionView->>NetworkExtensionAdapter: Start login
  NetworkExtensionAdapter->>NetBirdSDKNewAuth: Start authentication
  NetBirdSDKNewAuth-->>SafariView: Open authorize URL
  SafariView->>LoopbackListener: Replay captured redirect
  SafariView-->>iOSConnectionView: Report browser outcome
  iOSConnectionView->>NetworkExtensionAdapter: Resolve browser closure
  NetBirdSDKNewAuth-->>NetworkExtensionAdapter: Return success or error
  NetworkExtensionAdapter-->>iOSConnectionView: Update VPN login state
Loading

Possibly related PRs

Poem

A rabbit follows the redirect trail,
While Safari reports each result without fail.
Profiles mark the session’s name,
Fresh logins clear the stale-cookie claim.
The VPN starts when login is right,
And cancelled sessions exit cleanly at night.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 60.87% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly identifies the primary fix for repeated 2FA prompts during iOS SSO re-login.
Description check ✅ Passed The description includes the problem, implementation details, testing steps, known limitations, and recovery behavior.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@evgeniyChepelev evgeniyChepelev changed the title fix(ios): present the SSO login browser as a sheet fix(ios): repeated 2FA prompt during SSO re-login (iOS) Jul 27, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

🧹 Nitpick comments (2)
NetbirdKit/NetworkExtensionAdapter.swift (1)

562-572: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Nested [weak self] is redundant.

The outer closure already captures self weakly; the inner capture list re-captures the already-optional binding. Dropping the inner [weak self] (and keeping guard let self) reads cleaner — assuming it compiles as written today.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@NetbirdKit/NetworkExtensionAdapter.swift` around lines 562 - 572, Remove the
redundant `[weak self]` capture list from the inner `DispatchQueue.main.async`
closure in the `errListener.onErrorCallback` handler, while retaining the
existing `guard let self` and main-queue state updates.
NetBird/Source/App/Views/Components/SafariView.swift (1)

5-24: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Rename the file now that SafariView is gone.

The file no longer contains a Safari-based view; ProfileLoginWebView.swift (or similar) would match its contents.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@NetBird/Source/App/Views/Components/SafariView.swift` around lines 5 - 24,
Rename the source file from SafariView.swift to ProfileLoginWebView.swift so it
matches the ProfileLoginWebView view it defines, and update any project
references or imports that still use the old filename.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@NetBird/Source/App/Views/Components/SafariView.swift`:
- Around line 123-131: Update the failure handlers in SafariView’s
WKNavigationDelegate implementation so failures before successURLSeen is set
also surface an appropriate error state or close the view, rather than relying
solely on scheduleCloseIfNeeded(). Preserve the existing delayed-close behavior
for failures after the loopback redirect, and revise the nearby comment to
accurately describe both paths.
- Around line 100-110: Remove the print call in
webView(_:decidePolicyFor:decisionHandler:) so the success URL, including its
OAuth authorization code, is never logged. Preserve successURLSeen tracking and
the decisionHandler(.allow) behavior; if logging is required, use AppLogger with
only non-sensitive host/path information.

In `@NetBird/Source/App/Views/iOS/iOSConnectionView.swift`:
- Around line 202-214: The fallback timer in the login completion flow can
affect a later login attempt because it lacks attempt identity. Update the
relevant login handling around cancelPendingLogin() to create or advance a
monotonically increasing attempt token, capture it when scheduling the 20-second
closure, and only reset when the captured token still matches the current
attempt; preserve the existing loginSucceeded and showBrowser checks.
- Around line 171-182: Reset loginBrowserCompletionHandled independently of the
conditional ProfileLoginWebView content, preferably when the browser
presentation starts or at the end of onDismiss. Add an explicit fallback for a
nil or invalid loginURL so the sheet does not remain blank and the pending SDK
login is cancelled correctly; update the surrounding login presentation symbols
rather than relying on ProfileLoginWebView.onAppear.

In `@NetbirdKit/NetworkExtensionAdapter.swift`:
- Around line 584-591: The ownership-conflict recovery in performLogin must
handle logoutProfile failures explicitly. Replace try? with error handling that
logs the logout error and does not launch the retry when logout fails; reset
identityResetAttempted as needed so future attempts remain possible. Ensure the
ownership-conflict branch does not fall through to the outer IPC fallback that
clears pendingAuth, keeping the retry task isolated from that cleanup path.

---

Nitpick comments:
In `@NetBird/Source/App/Views/Components/SafariView.swift`:
- Around line 5-24: Rename the source file from SafariView.swift to
ProfileLoginWebView.swift so it matches the ProfileLoginWebView view it defines,
and update any project references or imports that still use the old filename.

In `@NetbirdKit/NetworkExtensionAdapter.swift`:
- Around line 562-572: Remove the redundant `[weak self]` capture list from the
inner `DispatchQueue.main.async` closure in the `errListener.onErrorCallback`
handler, while retaining the existing `guard let self` and main-queue state
updates.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: bd73c3bc-ebc1-4485-8896-0f33a663f823

📥 Commits

Reviewing files that changed from the base of the PR and between a69d8b0 and fb422c0.

📒 Files selected for processing (6)
  • NetBird/Source/App/Views/Components/SafariView.swift
  • NetBird/Source/App/Views/iOS/ProfilesListView.swift
  • NetBird/Source/App/Views/iOS/iOSConnectionView.swift
  • NetbirdKit/GlobalConstants.swift
  • NetbirdKit/NetworkExtensionAdapter.swift
  • NetbirdKit/Preferences.swift

Comment thread NetBird/Source/App/Views/Components/SafariView.swift Outdated
Comment thread NetBird/Source/App/Views/Components/SafariView.swift Outdated
Comment thread NetBird/Source/App/Views/iOS/iOSConnectionView.swift Outdated
Comment thread NetBird/Source/App/Views/iOS/iOSConnectionView.swift Outdated
Comment thread NetbirdKit/NetworkExtensionAdapter.swift Outdated
 don't log the loopback redirect URL — its query carries the OAuth
  authorization code; log only the fact and host
- rearm the login-sheet completion flag in onDismiss instead of the
  content's onAppear, and dismiss-and-cancel defensively if the sheet
  is ever presented without a valid login URL
- tag each login attempt with a token and check it in the 20 s
  post-dismissal fallback, so a stale timer can't cancel a newer
  attempt (including the automatic identity-reset retry)
- harden the ownership-conflict self-heal: skip the retry when the
  identity reset itself fails (try? previously hid the error and the
  retry re-presented the same conflicting key) and gate it on the
  browser phase having started, so it can't race the IPC fallback
- document why navigation-failure handlers act only after the loopback
  redirect: pre-success failures include benign NSURLErrorCancelled
  from superseded navigations, and reacting to them would abort
  healthy logins

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🧹 Nitpick comments (3)
NetbirdKit/NetworkExtensionAdapter.swift (1)

681-696: 🎯 Functional Correctness | 🔵 Trivial | 💤 Low value

prompt=none would be turned into an invalid combination.

OIDC forbids combining none with any other prompt value; appending select_account yields invalid_request. Treat none like login and leave the URL untouched (or replace it).

♻️ Proposed tweak
-            guard !values.contains("login"), !values.contains("select_account") else {
+            guard !values.contains("login"),
+                  !values.contains("select_account"),
+                  !values.contains("none") else {
                 return urlString
             }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@NetbirdKit/NetworkExtensionAdapter.swift` around lines 681 - 696, Update
urlForcingAccountSelection so an existing prompt value containing "none" returns
the original URL, alongside the existing "login" and "select_account"
exclusions. Preserve the current behavior for other prompt values by appending
"select_account" only when it does not create an invalid OIDC combination.
NetBird/Source/App/Views/Components/SafariView.swift (1)

144-149: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Fire-and-forget replay swallows delivery failures.

If the replay request fails (listener already gone, ATS, timeout), nothing is logged and the outcome is still reported as redirectCaptured — the adapter then waits out loginResolutionTimeout with no diagnostic. Log the completion (path/status only, never the query string).

🔍 Proposed logging
         private static func replayToLoopback(_ callbackURL: URL) {
             var request = URLRequest(url: callbackURL)
             request.timeoutInterval = 10
-            URLSession.shared.dataTask(with: request).resume()
+            URLSession.shared.dataTask(with: request) { _, response, error in
+                if let error {
+                    AppLogger.shared.log("Loopback replay failed: \(error.localizedDescription)")
+                } else if let http = response as? HTTPURLResponse {
+                    AppLogger.shared.log("Loopback replay status: \(http.statusCode)")
+                }
+            }.resume()
         }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@NetBird/Source/App/Views/Components/SafariView.swift` around lines 144 - 149,
Update SafariView.replayToLoopback to provide a completion handler for the
URLSession request, logging whether delivery succeeded or failed using only the
request path and HTTP status/error details; never log the callback URL’s query
string. Preserve the existing fire-and-forget behavior while ensuring failures
are observable.
NetBird/Source/App/Views/iOS/iOSConnectionView.swift (1)

165-199: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Replace the print calls with AppLogger.

These are the only diagnostics for a flow that is hard to reproduce; print output is invisible in shipped builds. .failed already logs through AppLogger — do the same for the other branches and drop the prints.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@NetBird/Source/App/Views/iOS/iOSConnectionView.swift` around lines 165 - 199,
Update loginBrowserDidFinish to remove all print calls and log the corresponding
diagnostics through AppLogger.shared.log in the .redirectCaptured and .closed
branches, including their completion handlers; retain the existing .failed
AppLogger logging and messages.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@NetBird/Source/App/Views/Components/SafariView.swift`:
- Around line 107-126: Update the ASWebAuthenticationSession setup in SafariView
so it does not use "http" as the callback scheme in either the iOS 17.4 or
legacy branch. Configure the SDK loopback redirect through a supported
custom-scheme or HTTPS callback path, keeping the completionHandler and
surrounding session lifecycle behavior unchanged.

In `@NetBird/Source/App/Views/iOS/iOSConnectionView.swift`:
- Around line 149-158: Update the browser presentation logic around SafariView
so that when showBrowser is true but loginURL cannot be parsed, it logs the
invalid URL and cancels the pending SDK login through the existing login
completion/cancellation flow. Preserve the current SafariView presentation for
valid URLs and ensure the fallback clears the browser state so the login cannot
remain pending.

In `@NetbirdKit/NetworkExtensionAdapter.swift`:
- Around line 786-816: Update probeListener so the connection state handler and
2-second watchdog execute on the same dedicated serial DispatchQueue rather than
the shared concurrent global queue. Start NWConnection with that queue and
schedule the timeout on it, preserving settle’s single-completion behavior while
keeping the existing probe outcomes unchanged.

---

Nitpick comments:
In `@NetBird/Source/App/Views/Components/SafariView.swift`:
- Around line 144-149: Update SafariView.replayToLoopback to provide a
completion handler for the URLSession request, logging whether delivery
succeeded or failed using only the request path and HTTP status/error details;
never log the callback URL’s query string. Preserve the existing fire-and-forget
behavior while ensuring failures are observable.

In `@NetBird/Source/App/Views/iOS/iOSConnectionView.swift`:
- Around line 165-199: Update loginBrowserDidFinish to remove all print calls
and log the corresponding diagnostics through AppLogger.shared.log in the
.redirectCaptured and .closed branches, including their completion handlers;
retain the existing .failed AppLogger logging and messages.

In `@NetbirdKit/NetworkExtensionAdapter.swift`:
- Around line 681-696: Update urlForcingAccountSelection so an existing prompt
value containing "none" returns the original URL, alongside the existing "login"
and "select_account" exclusions. Preserve the current behavior for other prompt
values by appending "select_account" only when it does not create an invalid
OIDC combination.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 0d745ec8-e813-438c-85c2-88daf2117282

📥 Commits

Reviewing files that changed from the base of the PR and between fb422c0 and 876c5a4.

📒 Files selected for processing (7)
  • NetBird/Info.plist
  • NetBird/Source/App/Views/Components/SafariView.swift
  • NetBird/Source/App/Views/iOS/iOSConnectionView.swift
  • NetbirdKit/GlobalConstants.swift
  • NetbirdKit/NetworkExtensionAdapter.swift
  • NetbirdKit/Preferences.swift
  • NetbirdKit/ProfileManager.swift
🚧 Files skipped from review as they are similar to previous changes (1)
  • NetbirdKit/Preferences.swift

Comment thread NetBird/Source/App/Views/Components/SafariView.swift Outdated
Comment thread NetBird/Source/App/Views/iOS/iOSConnectionView.swift
Comment thread NetbirdKit/NetworkExtensionAdapter.swift

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
NetbirdKit/NetworkExtensionAdapter.swift (2)

706-812: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Route all settle executions through a dedicated serial queue.

settled, settle, and connection.cancel() are currently shared across the connection handler queue and a global-watcher queue. A concurrent state update plus a concurrent 2s timeout can still race to cancel the connection or invoke completion multiple times, leaving this probe unstable. Serialize the resolution path in probeListener and set settled from the same serial queue.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@NetbirdKit/NetworkExtensionAdapter.swift` around lines 706 - 812, Update
probeListener so all settle invocations, settled checks/updates,
connection.cancel(), and completion calls execute on one dedicated serial queue.
Route both the NWConnection stateUpdateHandler and the two-second timeout
through that queue, preserving the existing first-resolution behavior while
preventing concurrent probe results from racing.

619-651: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Call pendingAuth?.stop() before dropping failed login state.

NetBirdSDKAuth.Stop() is what cancels the auth context, unblocks WaitToken, and shuts down the local OAuth loopback server. An SDK error path that only sets self.pendingAuth = nil leaves the failed flow’s listener and goroutine alive until the next login explicitly stops and replaces it.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@NetbirdKit/NetworkExtensionAdapter.swift` around lines 619 - 651, In the SDK
login failure handler inside performLogin, call pendingAuth?.stop() before
clearing pendingAuth and pendingAuthorizeURL. Preserve the existing main-thread
state cleanup and error-message handling, ensuring the failed authentication
context and loopback listener are stopped before dropping its reference.
♻️ Duplicate comments (1)
NetBird/Source/App/Views/iOS/iOSConnectionView.swift (1)

149-159: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Handle the unparsable-loginURL case.

When showBrowser is true but loginURL fails to parse, this if condition is false. No browser is shown, loginBrowserDidFinish is never called, and the pending SDK login stays open with no way to resolve. A previous review already flagged this exact gap; it is still present in this version of the code.

Add an else branch for the case where showBrowser is true but the URL is unparsable, and cancel the pending login from there.

🛡️ Proposed fallback
             if viewModel.networkExtensionAdapter.showBrowser,
                let loginURLString = viewModel.networkExtensionAdapter.loginURL,
                let loginURL = URL(string: loginURLString)
             {
                 SafariView(
                     isPresented: $viewModel.networkExtensionAdapter.showBrowser,
                     url: loginURL,
                     prefersEphemeralSession: viewModel.networkExtensionAdapter.useEphemeralBrowserSession,
                     didFinish: loginBrowserDidFinish
                 )
+            } else if viewModel.networkExtensionAdapter.showBrowser {
+                Color.clear.onAppear {
+                    AppLogger.shared.log("Login browser: unusable login URL — cancelling")
+                    viewModel.cancelPendingLogin()
+                }
             }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@NetBird/Source/App/Views/iOS/iOSConnectionView.swift` around lines 149 - 159,
Update the browser presentation logic in iOSConnectionView so that when
showBrowser is true but loginURL cannot be converted to a URL, it explicitly
cancels the pending SDK login. Preserve the existing SafariView path for valid
URLs and invoke the same cancellation behavior used by loginBrowserDidFinish for
the unparsable-URL fallback.
🧹 Nitpick comments (1)
NetBird/Source/App/Views/iOS/iOSConnectionView.swift (1)

173-209: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Log all browser-outcome transitions through AppLogger, not only print.

The .failed case logs through both print and AppLogger.shared.log. The .redirectCaptured and .closed cases log only through print, which is not persisted for diagnostics. This PR targets hard-to-reproduce login race conditions, so the transitions in these two cases are exactly the events most useful to have in the persisted log.

Route these messages through AppLogger.shared.log as well, to match the .failed case.

♻️ Proposed fix
         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")
+            AppLogger.shared.log("Login redirect captured - waiting for the SDK to finish")
             adapter.resolveLoginAfterBrowserClose {
-                print("Login did not complete after redirect - resetting")
+                AppLogger.shared.log("Login did not complete after redirect - resetting")
                 viewModel.cancelPendingLogin()
             }

         case .closed:
             if adapter.loginSucceeded {
-                print("Finish login")
+                AppLogger.shared.log("Finish login")
                 adapter.startVPNConnection()
                 return
             }
             ...
-            print("Login browser closed without a reported success - resolving")
+            AppLogger.shared.log("Login browser closed without a reported success - resolving")
             adapter.resolveLoginAfterBrowserClose {
-                print("Login cancelled or failed - resetting")
+                AppLogger.shared.log("Login cancelled or failed - resetting")
                 viewModel.cancelPendingLogin()
             }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@NetBird/Source/App/Views/iOS/iOSConnectionView.swift` around lines 173 - 209,
Update loginBrowserDidFinish so every transition message in the
.redirectCaptured and .closed cases is also sent to AppLogger.shared.log,
matching the existing .failed handling while preserving the current print output
and control flow.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@NetbirdKit/GlobalConstants.swift`:
- Around line 37-41: Update the documentation around keyLastAuthenticatedProfile
to match SafariView’s ASWebAuthenticationSession design: remove any claim of
per-profile WKWebsiteDataStore or iOS 17+ isolation, and describe the shared
non-ephemeral session instead. State that profile separation relies on
prompt=select_account and stored authenticated-profile ownership tracking.

---

Outside diff comments:
In `@NetbirdKit/NetworkExtensionAdapter.swift`:
- Around line 706-812: Update probeListener so all settle invocations, settled
checks/updates, connection.cancel(), and completion calls execute on one
dedicated serial queue. Route both the NWConnection stateUpdateHandler and the
two-second timeout through that queue, preserving the existing first-resolution
behavior while preventing concurrent probe results from racing.
- Around line 619-651: In the SDK login failure handler inside performLogin,
call pendingAuth?.stop() before clearing pendingAuth and pendingAuthorizeURL.
Preserve the existing main-thread state cleanup and error-message handling,
ensuring the failed authentication context and loopback listener are stopped
before dropping its reference.

---

Duplicate comments:
In `@NetBird/Source/App/Views/iOS/iOSConnectionView.swift`:
- Around line 149-159: Update the browser presentation logic in
iOSConnectionView so that when showBrowser is true but loginURL cannot be
converted to a URL, it explicitly cancels the pending SDK login. Preserve the
existing SafariView path for valid URLs and invoke the same cancellation
behavior used by loginBrowserDidFinish for the unparsable-URL fallback.

---

Nitpick comments:
In `@NetBird/Source/App/Views/iOS/iOSConnectionView.swift`:
- Around line 173-209: Update loginBrowserDidFinish so every transition message
in the .redirectCaptured and .closed cases is also sent to AppLogger.shared.log,
matching the existing .failed handling while preserving the current print output
and control flow.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 7841c139-cc75-425e-b275-d6c8011522c3

📥 Commits

Reviewing files that changed from the base of the PR and between 876c5a4 and 524f85a.

📒 Files selected for processing (7)
  • NetBird.xcodeproj/project.pbxproj
  • NetBird/Source/App/Views/Components/SafariView.swift
  • NetBird/Source/App/Views/iOS/iOSConnectionView.swift
  • NetbirdKit/GlobalConstants.swift
  • NetbirdKit/NetworkExtensionAdapter.swift
  • NetbirdKit/Preferences.swift
  • NetbirdKit/ProfileManager.swift
🚧 Files skipped from review as they are similar to previous changes (1)
  • NetBird/Source/App/Views/Components/SafariView.swift

Comment on lines +37 to +41
// Profile the persistent login browser session belongs to. The system auth
// session has a single cookie store shared by all profiles, so this records
// whose SSO and trusted-device state is currently in it: that profile's
// re-logins reuse the session, any other profile's login starts a fresh one.
static let keyLastAuthenticatedProfile = "netbird.lastAuthenticatedProfile"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail
fd -a 'SafariView\.swift$' .
if [ -f NetBird/Source/App/Views/Components/SafariView.swift ]; then
  rg -n -C5 'WKWebsiteDataStore|ASWebAuthenticationSession|prefersEphemeralSession|dataStore' NetBird/Source/App/Views/Components/SafariView.swift
fi

Repository: netbirdio/ios-client

Length of output: 4939


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== Candidate files =="
fd -a 'GlobalConstants\.swift$|Preferences\.swift$|NetworkExtensionAdapter\.swift$' .

echo
echo "== NetbirdKit/GlobalConstants.swift =="
if [ -f NetbirdKit/GlobalConstants.swift ]; then
  nl -ba NetbirdKit/GlobalConstants.swift | sed -n '1,120p'
fi

echo
echo "== NetworkExtensionAdapter usages of keyLastAuthenticatedProfile / authentication profile context =="
rg -n -C4 'keyLastAuthenticatedProfile|lastAuthenticatedProfile|profile.*auth|auth.*profile|prefersEphemeralSession|select_account' .

echo
echo "== Commit diff summary and focused hunks for relevant files =="
git diff --stat 2>/dev/null || true
git diff -- NetbirdKit/GlobalConstants.swift NetworkExtensionAdapter.swift NetBird/Source/App/Views/Components/SafariView.swift 2>/dev/null | sed -n '1,240p' || true

Repository: netbirdio/ios-client

Length of output: 421


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== Candidate files =="
fd -a 'GlobalConstants\.swift$|Preferences\.swift$|NetworkExtensionAdapter\.swift$' .

echo
echo "== NetbirdKit/GlobalConstants.swift =="
if [ -f NetbirdKit/GlobalConstants.swift ]; then
  awk '{printf "%6d\t%s\n", NR, $0}' NetbirdKit/GlobalConstants.swift | sed -n '1,160p'
fi

echo
echo "== NetworkExtensionAdapter.swift focus =="
if [ -f NetbirdKit/NetworkExtensionAdapter.swift ]; then
  awk '{printf "%6d\t%s\n", NR, $0}' NetbirdKit/NetworkExtensionAdapter.swift | sed -n '1,240p'
fi

echo
echo "== Preferences.swift cookie/context storage =="
if [ -f NetbirdKit/Preferences.swift ]; then
  rg -n -C4 'cookieStorage|WKWebsiteDataStore|ASWebAuthenticationSession|keyLastAuthenticatedProfile|lastAuthenticatedProfile|isolation|profile' NetbirdKit/Preferences.swift
  awk '{printf "%6d\t%s\n", NR, $0}' NetbirdKit/Preferences.swift | sed -n '1,220p'
fi

echo
echo "== SafariView.swift session configuration =="
awk '{printf "%6d\t%s\n", NR, $0}' NetBird/Source/App/Views/Components/SafariView.swift | sed -n '1,200p'

echo
echo "== Search ownership/session tracking across repo =="
rg -n -C3 'keyLastAuthenticatedProfile|lastAuthenticatedProfile|prefersEphemeralSession|select_account|WKWebsiteDataStore|ASWebAuthenticationSession' .

Repository: netbirdio/ios-client

Length of output: 50376


Align the per-profile auth-session design to ASWebAuthenticationSession.

SafariView.swift creates ASWebAuthenticationSession and does not use a per-profile WKWebsiteDataStore. It documents and uses prefersEphemeralWebBrowserSession with the shared JavaSafari jar for non-ephemeral logins. Update any per-profile WKWebsiteDataStore/iOS 17+ isolation claim, or restate isolation as prompt=select_account plus stored ownership tracking for the shared session.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@NetbirdKit/GlobalConstants.swift` around lines 37 - 41, Update the
documentation around keyLastAuthenticatedProfile to match SafariView’s
ASWebAuthenticationSession design: remove any claim of per-profile
WKWebsiteDataStore or iOS 17+ isolation, and describe the shared non-ephemeral
session instead. State that profile separation relies on prompt=select_account
and stored authenticated-profile ownership tracking.

@pappz

pappz commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

Why the cookie ownership approach falls short

The persistent session itself is fine. We need cookie persistence on iOS to keep the IdP's trusted device cookie alive. The problem is using cookie jar ownership to bind profiles to accounts.

  1. What we lose with the cookies only approach

Only one profile benefits. Safari has a single shared cookie jar, and the PR lets exactly one profile own it (lastAuthenticatedProfile). Every other profile is forced into an ephemeral (empty jar) session, which leaves nothing behind, so those profiles get the full password + OTP prompt on every login. With two or more profiles, the bug this PR fixes remains unfixed for all but one of them.

No email prefill. Fresh sessions (new profile, after logout, profile switch) start on a blank IdP page; the user types the email by hand.

The app never learns which account a profile belongs to, so we can't show it in the UI (the Android branch already displays it per profile).

Fragile heuristic. Ownership is tracked by profile name in UserDefaults while the actual state lives in Safari's cookie jar. The two can drift (Safari data cleared, system cookie purge), and the app can't detect it. There is also a gap: after logout the next login is ephemeral and doesn't claim ownership, so the login after that reuses the stored jar, which may still hold the SSO cookie of the account that was logged out and silently sign back into it.

No control over session extension. Silent reauthentication can land on the wrong account.

  1. Proposed solution: port the Android login_hint mechanism to iOS

The Go plumbing already exists in netbird core: the PKCE flow parses the user's email from the ID token, SetLoginHint exists on both flows, and desktop already stores the email in the profile state (profilemanager.GetLoginHint() reads it). Android just got the same treatment (fd06d9a, 6155c94b and follow ups): store the email per profile after a successful login, pass it as login_hint on every later interactive login and session extend, clear it on logout and profile removal. The iOS binding (client/ios) is the only one not wired up; its login path never sets the hint. Bringing it over means: keep this PR's persistent shared session for everyone, and let login_hint steer the IdP to the right account per profile instead of the ownership heuristic. Each profile then keeps its own trusted device state, logs in again without an OTP prompt, and the email prefills on fresh logins.

The upside: no separate implementations per platform. Account to profile binding works identically on desktop, Android, iOS, and TV: one mechanism in the Go core, maintained once, with the same logout and account switch semantics everywhere.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants