Files
buzz/mobile/ios/Runner/BuzzBrowserAuthBridge.swift
Wes 0d19ca69d3 fix(mobile): wait for app activation before browser auth (#8647)
## Summary

Opening an invite from Safari can deliver the login request while iOS
still considers Buzz inactive. `ASWebAuthenticationSession.start()` then
returns false, showing a connection error until the user retries. Wait
for app activation before starting the retained session, with a
ten-second timeout and cancellation cleanup.

Extract the existing browser-auth bridge from the size-limited
AppDelegate and add one native journey test through its Flutter method
channel. URL validation, callback fencing, and normal active-app login
retain their existing behavior. Temporary diagnostic logging and
generated dependency changes are excluded.

### Related issue

No duplicate found in the open handoff/authentication search. Related
invite-consent work: #8601; this fixes the subsequent iOS authentication
presentation race.

### Testing

- Focused `RunnerTests/BrowserAuthBridgeTests` passed on an iPhone 18
Pro / iOS 27 simulator. Covers inactive handoff, cancellation before
activation, retry, duplicate activation notifications, and immediate
active-app start.
- A local build reproduced the original inactive-app `start=false`
failure; the fixed build opened authentication on the first
background-to-app attempt. The same fixed build subsequently completed
an existing-account staging flow.
- Mutation check passed in isolated source, Git metadata, and build
outputs: clean control passed; bypassing the production activation wait
failed the inactive-handoff assertion; restored source passed again,
with its SHA-256 verified against the committed file.
- Self-review found no blocking issue. CI and final review remain
pending. Mandatory pre-commit and pre-push hooks passed, including
mobile analysis and the hook-run Flutter suite. No full local suite was
manually invoked.
- Human acceptance: from Safari, tap **Accept invite in Buzz** and
confirm sign-in opens on the first attempt without **Try again**. Final
acceptance of this PR remains pending.

The separately reported callback failure may have involved production
rather than staging. That cause is unconfirmed and is outside this
change.

Signed-off-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Brain <1a02c72794dcd0f07058a353bc3a81f4028b8c77c92c87fce6d5c8b85970a20b@buzz.block.builderlab.xyz>
2026-10-29 09:54:21 -06:00

111 lines
4.3 KiB
Swift

import AuthenticationServices
import Flutter
import UIKit
/// Keeps the authentication callback scoped to the system browser session.
final class BuzzBrowserAuthBridge: NSObject, ASWebAuthenticationPresentationContextProviding {
private let channel: FlutterMethodChannel
private var session: ASWebAuthenticationSession?
private var pending: FlutterResult?
private var attemptID: UUID?
private let isActive: () -> Bool
private let startSession: (ASWebAuthenticationSession) -> Bool
private var activationObserver: NSObjectProtocol?
private var activationTimeout: DispatchWorkItem?
init(
messenger: FlutterBinaryMessenger,
isActive: @escaping () -> Bool = { UIApplication.shared.applicationState == .active },
startSession: @escaping (ASWebAuthenticationSession) -> Bool = { $0.start() }
) {
self.isActive = isActive
self.startSession = startSession
channel = FlutterMethodChannel(name: "buzz/browser_auth", binaryMessenger: messenger)
super.init()
channel.setMethodCallHandler { [weak self] call, result in
guard let self else { return }
if call.method == "cancel" {
self.session?.cancel()
self.finish(nil, code: "cancelled")
result(nil)
return
}
guard call.method == "start" else { result(FlutterMethodNotImplemented); return }
guard self.session == nil,
let args = call.arguments as? [String: String],
let raw = args["url"], let url = URL(string: raw),
url.scheme == "https",
["app.builderlab.xyz", "app.builderlabstaging.xyz"].contains(url.host ?? ""),
url.path == "/api/goose/v1/auth/login", url.user == nil, url.password == nil,
url.port == nil, url.fragment == nil
else { result(FlutterError(code: "unavailable", message: nil, details: nil)); return }
self.pending = result
let attemptID = UUID()
self.attemptID = attemptID
let session = ASWebAuthenticationSession(url: url, callbackURLScheme: "xyz.block.buzz.mobile") { [weak self] callback, error in
DispatchQueue.main.async {
guard self?.attemptID == attemptID else { return }
let cancelled = (error as? ASWebAuthenticationSessionError)?.code == .canceledLogin
self?.finish(callback?.absoluteString, code: cancelled ? "cancelled" : "unavailable")
}
}
session.presentationContextProvider = self
session.prefersEphemeralWebBrowserSession = false
#if DEBUG
session.prefersEphemeralWebBrowserSession = args["ephemeral"] == "true"
#endif
self.session = session
self.startWhenActive(attemptID)
}
}
// Safari delivers a deep link before UIKit finishes activating our app.
// ASWebAuthenticationSession.start() rejects presentation during that handoff.
private func startWhenActive(_ id: UUID) {
guard attemptID == id, let session else { return }
if isActive() {
clearActivationWait()
if !startSession(session) { finish(nil, code: "unavailable") }
return
}
activationObserver = NotificationCenter.default.addObserver(
forName: UIApplication.didBecomeActiveNotification, object: nil, queue: .main
) { [weak self] _ in
guard let self, self.attemptID == id, self.isActive() else { return }
self.startWhenActive(id)
}
let timeout = DispatchWorkItem { [weak self] in
guard let self, self.attemptID == id else { return }
self.finish(nil, code: "unavailable")
}
activationTimeout = timeout
DispatchQueue.main.asyncAfter(deadline: .now() + 10, execute: timeout)
}
private func clearActivationWait() {
if let activationObserver { NotificationCenter.default.removeObserver(activationObserver) }
activationObserver = nil
activationTimeout?.cancel()
activationTimeout = nil
}
deinit { clearActivationWait() }
private func finish(_ callback: String?, code: String) {
clearActivationWait()
let result = pending
attemptID = nil
pending = nil
session = nil
if let callback { result?(callback) }
else { result?(FlutterError(code: code, message: nil, details: nil)) }
}
func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor {
UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }
.flatMap { $0.windows }.first(where: { $0.isKeyWindow }) ?? ASPresentationAnchor()
}
}