Skip to content

Commit 96c1e2c

Browse files
Add FXIOS-16685 [FxA Pairflow] Support pairing v2 via WebChannel OAuth Part 1 (#35460)
Add FXIOS-16685 [FxA Pairflow] Support pairing v2 via WebChannel OAuth Load the pairing deep link directly in the WebChannel web view, instead of converting it to a supplicant URL through beginPairingAuthentication. The page then asks the app for OAuth parameters with fxaccounts:pair_oauth_start, and FxAPairingOAuthHandler answers with the state, scope, code_challenge, code_challenge_method and keys_jwk it reads back from the flow. Advertise pairingVersion 2 in the fxaccounts:fxa_status capabilities, so the content server can choose the v2 path. The server gates the flow on its own pairing.version config as well, so both sides must agree before v2 runs. Give the deep link its own FxAPageType. The qrCode case also serves the in-app scanner and the debug setting. Both start their own OAuth flow before the page loads, so neither may satisfy the pair_oauth_start gate. Wait for the account manager before presenting the modal, so a cold-launch deep link is not dropped. The wait runs a @sendable closure off the main actor, so hop back through ensureMainThread, matching the other AppEventQueue.wait call sites in that file. Track the wait token and cancel it in deinit. Reject WebChannel messages from subframes, and reply to a pair_oauth_start the app cannot serve rather than leaving the page waiting. Log when a reply is dropped because the view model or the web view went away. Extract the WebChannel reply envelope into webChannelReplyScript and test it. The envelope is a protocol contract with the content server: the page matches the reply against the id it sent, so messageId is emitted as a number rather than a string. Cover the redirect policy after login, the title composition, the user script setup and message id parsing, none of which had tests. Co-authored-by: Matt Lichtenstein <lichtensteinmp@gmail.com>
1 parent 8b84e1d commit 96c1e2c

7 files changed

Lines changed: 620 additions & 39 deletions

File tree

firefox-ios/Client.xcodeproj/project.pbxproj

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,8 @@
6666
0AC0F9482E3CA17900E826D8 /* MainMenuConfigurationUtilityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0AC0F9472E3CA17500E826D8 /* MainMenuConfigurationUtilityTests.swift */; };
6767
0AC659272BF35854005C614A /* FxAWebViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0AC659262BF35854005C614A /* FxAWebViewModelTests.swift */; };
6868
0AC659292BF493CE005C614A /* MockFxAWebViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0AC659282BF493CE005C614A /* MockFxAWebViewModel.swift */; };
69+
0AC6592B2BF50000005C614A /* FxAPairingOAuthHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0AC6592A2BF50000005C614A /* FxAPairingOAuthHandler.swift */; };
70+
0AC6592D2BF50000005C614A /* FxAPairingOAuthHandlerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0AC6592C2BF50000005C614A /* FxAPairingOAuthHandlerTests.swift */; };
6971
0AD315DC2F8FBECB00DAD6D1 /* TabCellCustomImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0AD315DB2F8FBEBA00DAD6D1 /* TabCellCustomImageView.swift */; };
7072
0AD3EEAC2C2485A7001044E5 /* ThemedCenteredTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0AD3EEAB2C2485A7001044E5 /* ThemedCenteredTableViewCell.swift */; };
7173
0AEA26D42F506C1900B512EB /* BlockedTrackersLearnMoreViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0AEA26D32F506BDA00B512EB /* BlockedTrackersLearnMoreViewController.swift */; };
@@ -2853,6 +2855,8 @@
28532855
0AC0F9472E3CA17500E826D8 /* MainMenuConfigurationUtilityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainMenuConfigurationUtilityTests.swift; sourceTree = "<group>"; };
28542856
0AC659262BF35854005C614A /* FxAWebViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FxAWebViewModelTests.swift; sourceTree = "<group>"; };
28552857
0AC659282BF493CE005C614A /* MockFxAWebViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockFxAWebViewModel.swift; sourceTree = "<group>"; };
2858+
0AC6592A2BF50000005C614A /* FxAPairingOAuthHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FxAPairingOAuthHandler.swift; sourceTree = "<group>"; };
2859+
0AC6592C2BF50000005C614A /* FxAPairingOAuthHandlerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FxAPairingOAuthHandlerTests.swift; sourceTree = "<group>"; };
28562860
0AD315DB2F8FBEBA00DAD6D1 /* TabCellCustomImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabCellCustomImageView.swift; sourceTree = "<group>"; };
28572861
0AD3EEAB2C2485A7001044E5 /* ThemedCenteredTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThemedCenteredTableViewCell.swift; sourceTree = "<group>"; };
28582862
0AE9462E8A8E05CE07D4973D /* it */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = it; path = it.lproj/InfoPlist.strings; sourceTree = "<group>"; };
@@ -16304,6 +16308,7 @@
1630416308
CDB3BE8624746787009320EE /* FirefoxAccountSignInViewController.swift */,
1630516309
8ADC2A112A3375B900543DAA /* FxAEntryPoint.swift */,
1630616310
8ADC2A0F2A33758E00543DAA /* FxALaunchParams.swift */,
16311+
0AC6592A2BF50000005C614A /* FxAPairingOAuthHandler.swift */,
1630716312
8ADC2A172A33775F00543DAA /* FxASignInViewParameters.swift */,
1630816313
C8E2E80723D20FB3005AACE6 /* FxAWebViewController.swift */,
1630916314
5F130D2D2483508E00B0F7D0 /* FxAWebViewModel.swift */,
@@ -17534,6 +17539,7 @@
1753417539
C787D8C22C1CB77900940123 /* FirefoxAccountSignInViewControllerTests.swift */,
1753517540
E1AEC16D286E0CF500062E29 /* Frontend */,
1753617541
3943A81C1E9807C700D4F6DC /* FxAPushMessageTest.swift */,
17542+
0AC6592C2BF50000005C614A /* FxAPairingOAuthHandlerTests.swift */,
1753717543
0AC659262BF35854005C614A /* FxAWebViewModelTests.swift */,
1753817544
8C6AF2692D38FB1500254698 /* GleanLifecycleObserverTests.swift */,
1753917545
E1E6F8CD29D4B7E700068D8D /* GleanPlumbContextProviderTests.swift */,
@@ -19779,6 +19785,7 @@
1977919785
BA1237C12DAE6D2C00BB6333 /* AddressBarSelectionView.swift in Sources */,
1978019786
8A4EA0D12C010BE700E4E4F1 /* MicrosurveySurfaceManager.swift in Sources */,
1978119787
5F130D2E2483508E00B0F7D0 /* FxAWebViewModel.swift in Sources */,
19788+
0AC6592B2BF50000005C614A /* FxAPairingOAuthHandler.swift in Sources */,
1978219789
D0FCF7F51FE45842004A7995 /* UserScriptManager.swift in Sources */,
1978319790
5A9F83422B2B796800272819 /* TabPeekState.swift in Sources */,
1978419791
C796E22A2E3D48D3005D6A1D /* ShortcutsLibraryViewController.swift in Sources */,
@@ -21272,6 +21279,7 @@
2127221279
C8E78BDD27F4A1E700C48BAA /* HistoryDeletionUtilityTests.swift in Sources */,
2127321280
8A3EAFE52D5F9AA40060AB87 /* TabManagerMiddlewareTests.swift in Sources */,
2127421281
0AC659272BF35854005C614A /* FxAWebViewModelTests.swift in Sources */,
21282+
0AC6592D2BF50000005C614A /* FxAPairingOAuthHandlerTests.swift in Sources */,
2127521283
8AB893A72C73AFCC00DAEED7 /* MockLoginViewModelDelegate.swift in Sources */,
2127621284
8AC8841F2D5261170033ABF5 /* MockCrashTracker.swift in Sources */,
2127721285
8A11C8132731E54800AC7318 /* DictionaryExtensionsTests.swift in Sources */,

firefox-ios/Client/Frontend/Browser/BrowserViewController/Views/BrowserViewController.swift

Lines changed: 36 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,6 @@ import struct MozillaAppServices.Login
2525
import enum MozillaAppServices.BookmarkRoots
2626
import struct MozillaAppServices.VisitObservation
2727
import enum MozillaAppServices.VisitType
28-
import enum MozillaAppServices.OAuthScope
2928

3029
class BrowserViewController: UIViewController,
3130
SearchBarLocationProvider,
@@ -266,6 +265,9 @@ class BrowserViewController: UIViewController,
266265

267266
var navigationHintDoubleTapTimer: Timer?
268267
var googleLensTipObservationTask: Task<Void, Never>?
268+
269+
/// Outstanding wait for the account manager before the pairing modal can present.
270+
private var pairingWaitToken: ActionToken?
269271
weak var googleLensTipViewController: UIViewController?
270272
private(set) lazy var navigationContextHintVC: ContextualHintViewController = {
271273
let navigationViewProvider = ContextualHintViewProvider(forHintType: .navigation, with: profile)
@@ -509,6 +511,9 @@ class BrowserViewController: UIViewController,
509511
unsubscribeFromRedux()
510512
stopObservingAllWebViews()
511513
googleLensTipObservationTask?.cancel()
514+
if let pairingWaitToken {
515+
AppEventQueue.cancelAction(token: pairingWaitToken)
516+
}
512517
}
513518
}
514519

@@ -3407,27 +3412,37 @@ class BrowserViewController: UIViewController,
34073412
}
34083413

34093414
func presentPairingViewController(_ pairingURL: URL) {
3410-
guard let accountManager = profile.rustFxA.accountManager else { return }
3411-
3412-
accountManager.beginPairingAuthentication(
3413-
pairingUrl: pairingURL.absoluteString,
3414-
entrypoint: "pairing_\(FxAEntrypoint.fxaDeepLinkNavigation.rawValue)",
3415-
scopes: [OAuthScope.profile, OAuthScope.oldSync, OAuthScope.session]
3416-
) { [weak self] result in
3417-
guard let self, case .success(let supplicantURL) = result else { return }
3418-
let viewController = FxAWebViewController(
3419-
pageType: .qrCode(url: supplicantURL),
3420-
profile: profile,
3421-
dismissalStyle: .dismiss,
3422-
deepLinkParams: FxALaunchParams(entrypoint: .fxaDeepLinkNavigation, query: [:])
3423-
)
3424-
presentThemedViewController(
3425-
navItemLocation: .Left,
3426-
navItemText: .Close,
3427-
vcBeingPresented: viewController,
3428-
topTabsVisible: UIDevice.current.userInterfaceIdiom == .pad
3429-
)
3415+
// Without an account manager the web view never loads its first page, so the modal would
3416+
// present empty with no error and no way out. A cold-launch deep link can arrive before the
3417+
// account manager finishes initializing, so wait for it rather than dropping the route.
3418+
pairingWaitToken = AppEventQueue.wait(for: .accountManagerInitialized) { [weak self] in
3419+
ensureMainThread { [weak self] in
3420+
self?.pairingWaitToken = nil
3421+
self?.presentPairingWebView(pairingURL)
3422+
}
3423+
}
3424+
}
3425+
3426+
private func presentPairingWebView(_ pairingURL: URL) {
3427+
guard profile.rustFxA.accountManager != nil else {
3428+
logger.log("Cannot present the pairing flow without an account manager",
3429+
level: .warning,
3430+
category: .sync)
3431+
return
34303432
}
3433+
3434+
let viewController = FxAWebViewController(
3435+
pageType: .pairingV2(url: pairingURL),
3436+
profile: profile,
3437+
dismissalStyle: .dismiss,
3438+
deepLinkParams: FxALaunchParams(entrypoint: .fxaDeepLinkNavigation, query: [:])
3439+
)
3440+
presentThemedViewController(
3441+
navItemLocation: .Left,
3442+
navItemText: .Close,
3443+
vcBeingPresented: viewController,
3444+
topTabsVisible: UIDevice.current.userInterfaceIdiom == .pad
3445+
)
34313446
}
34323447

34333448
// MARK: - Handle Deeplink open URL / query

firefox-ios/Client/Frontend/Settings/Main/Debug/LaunchPairingFromURLSetting.swift

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -70,9 +70,7 @@ class LaunchPairingFromURLSetting: HiddenSetting {
7070
let profile = settings.profile,
7171
let accountManager = pairingAuthenticatorProvider() else { return }
7272

73-
// Converts the raw /pair URL into the supplicant OAuth URL; loading /pair
74-
// directly sends non-desktop browsers to /pair/unsupported. Mirrors the
75-
// real-scan path in FirefoxAccountSignInViewController.
73+
// Mirrors the in-app QR scanner path in FirefoxAccountSignInViewController.
7674
accountManager.beginPairingAuthentication(
7775
pairingUrl: pairingUrl,
7876
entrypoint: "pairing_debug",
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
// This Source Code Form is subject to the terms of the Mozilla Public
2+
// License, v. 2.0. If a copy of the MPL was not distributed with this
3+
// file, You can obtain one at http://mozilla.org/MPL/2.0/
4+
5+
import Foundation
6+
import Common
7+
8+
import class MozillaAppServices.FxAccountManager
9+
import enum MozillaAppServices.OAuthScope
10+
11+
@MainActor
12+
protocol FxAOAuthAuthenticating: AnyObject {
13+
func beginAuthentication(entrypoint: String,
14+
scopes: [String],
15+
completionHandler: @escaping @MainActor @Sendable (Result<URL, Error>) -> Void
16+
)
17+
}
18+
19+
extension FxAccountManager: FxAOAuthAuthenticating {}
20+
21+
enum FxAPairingOAuthHandlerError: Error, Sendable {
22+
case failedToStart
23+
}
24+
25+
@MainActor
26+
final class FxAPairingOAuthHandler {
27+
static let errorMessage = "Failed to begin a pairing OAuth flow"
28+
29+
private static let entrypoint = "webchannel-pairing"
30+
private static let scopes = [OAuthScope.profile, OAuthScope.oldSync, OAuthScope.session]
31+
private static let parameterNames = ["state", "scope", "code_challenge", "code_challenge_method", "keys_jwk"]
32+
33+
private let authenticatorProvider: () -> FxAOAuthAuthenticating?
34+
private let logger: Logger
35+
36+
init(authenticatorProvider: @escaping () -> FxAOAuthAuthenticating?,
37+
logger: Logger = DefaultLogger.shared) {
38+
self.authenticatorProvider = authenticatorProvider
39+
self.logger = logger
40+
}
41+
42+
func start(
43+
completion: @escaping @MainActor @Sendable (Result<[String: String], FxAPairingOAuthHandlerError>) -> Void
44+
) {
45+
guard let authenticator = authenticatorProvider() else {
46+
completion(.failure(.failedToStart))
47+
return
48+
}
49+
50+
authenticator.beginAuthentication(
51+
entrypoint: Self.entrypoint,
52+
scopes: Self.scopes
53+
) { [weak self] result in
54+
// The page is waiting on this reply, so a deallocated handler must still resolve it.
55+
guard let self else {
56+
completion(.failure(.failedToStart))
57+
return
58+
}
59+
60+
switch result {
61+
case .success(let url):
62+
guard let parameters = parameters(from: url) else {
63+
completion(.failure(.failedToStart))
64+
return
65+
}
66+
completion(.success(parameters))
67+
case .failure(let error):
68+
logger.log("Failed to begin a pairing OAuth flow: \(error.localizedDescription)",
69+
level: .warning,
70+
category: .sync)
71+
completion(.failure(.failedToStart))
72+
}
73+
}
74+
}
75+
76+
func parameters(from url: URL) -> [String: String]? {
77+
guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { return nil }
78+
var parameters = [String: String]()
79+
80+
for name in Self.parameterNames {
81+
guard let value = components.queryItems?.first(where: { $0.name == name })?.value else {
82+
logger.log("Pairing OAuth URL is missing the \(name) parameter", level: .warning, category: .sync)
83+
return nil
84+
}
85+
parameters[name] = value
86+
}
87+
88+
return parameters
89+
}
90+
}

0 commit comments

Comments
 (0)