fix(ios): repeated 2FA prompt during SSO re-login (iOS) - #175
fix(ios): repeated 2FA prompt during SSO re-login (iOS)#175evgeniyChepelev wants to merge 5 commits into
Conversation
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (2)
🚧 Files skipped from review as they are similar to previous changes (2)
📝 WalkthroughWalkthroughThe 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. ChangesProfile login flow
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
Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
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. Comment |
There was a problem hiding this comment.
Actionable comments posted: 5
🧹 Nitpick comments (2)
NetbirdKit/NetworkExtensionAdapter.swift (1)
562-572: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueNested
[weak self]is redundant.The outer closure already captures
selfweakly; the inner capture list re-captures the already-optional binding. Dropping the inner[weak self](and keepingguard 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 valueRename the file now that
SafariViewis 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
📒 Files selected for processing (6)
NetBird/Source/App/Views/Components/SafariView.swiftNetBird/Source/App/Views/iOS/ProfilesListView.swiftNetBird/Source/App/Views/iOS/iOSConnectionView.swiftNetbirdKit/GlobalConstants.swiftNetbirdKit/NetworkExtensionAdapter.swiftNetbirdKit/Preferences.swift
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
There was a problem hiding this comment.
Actionable comments posted: 3
🧹 Nitpick comments (3)
NetbirdKit/NetworkExtensionAdapter.swift (1)
681-696: 🎯 Functional Correctness | 🔵 Trivial | 💤 Low value
prompt=nonewould be turned into an invalid combination.OIDC forbids combining
nonewith any other prompt value; appendingselect_accountyieldsinvalid_request. Treatnonelikeloginand 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 valueFire-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 outloginResolutionTimeoutwith 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 winReplace the
AppLogger.These are the only diagnostics for a flow that is hard to reproduce;
.failedalready logs throughAppLogger— 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
📒 Files selected for processing (7)
NetBird/Info.plistNetBird/Source/App/Views/Components/SafariView.swiftNetBird/Source/App/Views/iOS/iOSConnectionView.swiftNetbirdKit/GlobalConstants.swiftNetbirdKit/NetworkExtensionAdapter.swiftNetbirdKit/Preferences.swiftNetbirdKit/ProfileManager.swift
🚧 Files skipped from review as they are similar to previous changes (1)
- NetbirdKit/Preferences.swift
There was a problem hiding this comment.
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 winRoute all
settleexecutions through a dedicated serial queue.
settled,settle, andconnection.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 inprobeListenerand setsettledfrom 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 winCall
pendingAuth?.stop()before dropping failed login state.
NetBirdSDKAuth.Stop()is what cancels the auth context, unblocksWaitToken, and shuts down the local OAuth loopback server. An SDK error path that only setsself.pendingAuth = nilleaves 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 winHandle the unparsable-
loginURLcase.When
showBrowseristruebutloginURLfails to parse, thisifcondition isfalse. No browser is shown,loginBrowserDidFinishis 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
elsebranch for the case whereshowBrowseristruebut 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 winLog all browser-outcome transitions through
AppLogger, not onlyThe
.failedcase logs through bothAppLogger.shared.log. The.redirectCapturedand.closedcases log only throughRoute these messages through
AppLogger.shared.logas well, to match the.failedcase.♻️ 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
📒 Files selected for processing (7)
NetBird.xcodeproj/project.pbxprojNetBird/Source/App/Views/Components/SafariView.swiftNetBird/Source/App/Views/iOS/iOSConnectionView.swiftNetbirdKit/GlobalConstants.swiftNetbirdKit/NetworkExtensionAdapter.swiftNetbirdKit/Preferences.swiftNetbirdKit/ProfileManager.swift
🚧 Files skipped from review as they are similar to previous changes (1)
- NetBird/Source/App/Views/Components/SafariView.swift
| // 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" |
There was a problem hiding this comment.
🗄️ 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
fiRepository: 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' || trueRepository: 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.
|
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.
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.
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. |
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'strusted-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
WKWebView(ProfileLoginWebView) presentedas a sheet. Each profile gets its own persistent
WKWebsiteDataStore(forIdentifier:)keyed by a stable per-profileUUID (iOS 17+), so:
asked only on the first login of a profile;
another profile's IdP session.
(same isolation, OTP on each login).
the same name inherits nothing.
Login-flow correctness fixes
loading (token exchange done). Closing on a timer could cancel the
in-flight
localhost:53000request, which cancelled the tokenexchange itself and silently killed the login.
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.
being silently discarded.
peer is already registered by a different User or a Setup Key: the profile's local identity (config + state, server URLpreserved) 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.
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.
full login (password + OTP). The sheet closes on its own and the VPN
connects even though registration finishes after the sheet closed.
same as scenario 1. Each profile keeps its own trusted-device state.
switch between them — a login for profile B must never auto-complete
with profile A's session.
swipe-down before completing the login → VPN must not start, no
"Login required" alert, connect button returns to Disconnected.
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_useragentpolicy) 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).
Need help on this PR? Tag
@codesmith-botwith what you need. Autofix is disabled.Summary by CodeRabbit
New Features
Bug Fixes