Skip to content

Commit

Permalink
[Connect] Support opening authenticated web views (#4144)
Browse files Browse the repository at this point in the history
## Summary
Adds support for the `openAuthenticatedWebView` JS messenger. When the
web view sends this message to the mobile SDK, we open an
`ASWebAuthenticatedSession` to the given URL and then call
`window.returnedFromAuthenticatedWebView` with the return URL.

Also adds id to `returnedFromAuthenticatedWebView` payload so that web
layer can associate the callback with the original open request.

## Motivation
https://jira.corp.stripe.com/browse/MXMOBILE-2578

## Testing

| Scenario | Unit test coverage | Manually tested |
| -------------------------------- | ------------------ |
---------------- |
| User cancel action | ✓ | ✓ modal + navbar |
| Error state: multiple sessions | ✓ | ✓ |
| Error state: session can't start | ✓ | ✓ background app |
| Error state: no window | ✓ | |
| Error state: misc error | ✓ | |
| Successfully redirect | ✓ | ✓ |

Because this callback is not yet implemented on web, manual testing was
performed by directly invoking the handler in the Safari debug console
(sound on):


https://github.com/user-attachments/assets/f8a0b764-6d4d-4d68-8d95-fd325dae3fee
  • Loading branch information
mludowise-stripe authored Oct 18, 2024
1 parent cafce5a commit 2d1e81f
Show file tree
Hide file tree
Showing 10 changed files with 386 additions and 10 deletions.
28 changes: 28 additions & 0 deletions StripeConnect/StripeConnect.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,9 @@
41D17A6E2C5A7429007C6EE6 /* Version.xcconfig in Resources */ = {isa = PBXBuildFile; fileRef = 41D17A632C5A7429007C6EE6 /* Version.xcconfig */; };
E6165CBF2CA7BF2200B76DA5 /* FetchInitComponentPropsMessageHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6165CBE2CA7BF2200B76DA5 /* FetchInitComponentPropsMessageHandler.swift */; };
E6165CC12CA7D09900B76DA5 /* FetchInitComponentPropsMessageHandlerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6165CC02CA7D09900B76DA5 /* FetchInitComponentPropsMessageHandlerTests.swift */; };
E640C9CC2CBF0C1E009D0C6E /* AuthenticatedWebViewManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = E640C9CB2CBF0C1E009D0C6E /* AuthenticatedWebViewManager.swift */; };
E640C9CF2CBF26DE009D0C6E /* AuthenticatedWebViewError.swift in Sources */ = {isa = PBXBuildFile; fileRef = E640C9CE2CBF26D5009D0C6E /* AuthenticatedWebViewError.swift */; };
E640C9D32CBF479E009D0C6E /* AuthenticatedWebViewManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E640C9D22CBF4799009D0C6E /* AuthenticatedWebViewManagerTests.swift */; };
E65691202CA5248300E0DB00 /* AccountManagementViewControllerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E656911F2CA5248300E0DB00 /* AccountManagementViewControllerTests.swift */; };
E65691222CA52D5900E0DB00 /* StripeConnect+Exports.swift in Sources */ = {isa = PBXBuildFile; fileRef = E65691212CA52D5900E0DB00 /* StripeConnect+Exports.swift */; };
E65691252CA52F9D00E0DB00 /* NotificationBannerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = E65691232CA52F8600E0DB00 /* NotificationBannerViewController.swift */; };
Expand Down Expand Up @@ -197,6 +200,9 @@
41D17A632C5A7429007C6EE6 /* Version.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Version.xcconfig; sourceTree = "<group>"; };
E6165CBE2CA7BF2200B76DA5 /* FetchInitComponentPropsMessageHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FetchInitComponentPropsMessageHandler.swift; sourceTree = "<group>"; };
E6165CC02CA7D09900B76DA5 /* FetchInitComponentPropsMessageHandlerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FetchInitComponentPropsMessageHandlerTests.swift; sourceTree = "<group>"; };
E640C9CB2CBF0C1E009D0C6E /* AuthenticatedWebViewManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthenticatedWebViewManager.swift; sourceTree = "<group>"; };
E640C9CE2CBF26D5009D0C6E /* AuthenticatedWebViewError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthenticatedWebViewError.swift; sourceTree = "<group>"; };
E640C9D22CBF4799009D0C6E /* AuthenticatedWebViewManagerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthenticatedWebViewManagerTests.swift; sourceTree = "<group>"; };
E656911F2CA5248300E0DB00 /* AccountManagementViewControllerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountManagementViewControllerTests.swift; sourceTree = "<group>"; };
E65691212CA52D5900E0DB00 /* StripeConnect+Exports.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "StripeConnect+Exports.swift"; sourceTree = "<group>"; };
E65691232CA52F8600E0DB00 /* NotificationBannerViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationBannerViewController.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -294,6 +300,7 @@
413987C62C63F34B001D375E /* Internal */ = {
isa = PBXGroup;
children = (
E640C9CD2CBF26C9009D0C6E /* AuthenticatedWebView */,
416E9ED02C77F6C100A0B917 /* Extensions */,
410D0FE22C6D31C6009B0E26 /* StripeConnectConstants.swift */,
413987C42C63F34B001D375E /* Webview */,
Expand Down Expand Up @@ -390,6 +397,7 @@
41814EE62C6BC8690014EB5E /* Internal */ = {
isa = PBXGroup;
children = (
E640C9D12CBF4790009D0C6E /* AuthenticatedWebView */,
41814EE52C6BC8610014EB5E /* Webview */,
);
path = Internal;
Expand Down Expand Up @@ -531,6 +539,23 @@
path = ../BuildConfigurations;
sourceTree = "<group>";
};
E640C9CD2CBF26C9009D0C6E /* AuthenticatedWebView */ = {
isa = PBXGroup;
children = (
E640C9CB2CBF0C1E009D0C6E /* AuthenticatedWebViewManager.swift */,
E640C9CE2CBF26D5009D0C6E /* AuthenticatedWebViewError.swift */,
);
path = AuthenticatedWebView;
sourceTree = "<group>";
};
E640C9D12CBF4790009D0C6E /* AuthenticatedWebView */ = {
isa = PBXGroup;
children = (
E640C9D22CBF4799009D0C6E /* AuthenticatedWebViewManagerTests.swift */,
);
path = AuthenticatedWebView;
sourceTree = "<group>";
};
E688AE012CADE35300951D97 /* NotificationBanner */ = {
isa = PBXGroup;
children = (
Expand Down Expand Up @@ -697,6 +722,7 @@
E6D3C8EE2CBE1404003CE967 /* HTTPStatusError.swift in Sources */,
413987CC2C63F34B001D375E /* VoidPayload.swift in Sources */,
E65691272CA533CD00E0DB00 /* OnNotificationsChangeHandler.swift in Sources */,
E640C9CF2CBF26DE009D0C6E /* AuthenticatedWebViewError.swift in Sources */,
413987DD2C640A29001D375E /* FetchInitParamsMessageHandler.swift in Sources */,
413987D42C640848001D375E /* CallSetterWithSerializableValueSender.swift in Sources */,
416E9E742C751A1A00A0B917 /* ConnectComponentWebViewController.swift in Sources */,
Expand All @@ -719,6 +745,7 @@
E6F485F82C9E35A5000D914F /* PaymentDetailsViewController.swift in Sources */,
416E9E762C751B0500A0B917 /* EmbeddedComponentManager.swift in Sources */,
416E9ECF2C77EAA400A0B917 /* EmbeddedComponentError.swift in Sources */,
E640C9CC2CBF0C1E009D0C6E /* AuthenticatedWebViewManager.swift in Sources */,
410D0FDF2C6D3176009B0E26 /* ConnectWebViewController.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
Expand All @@ -740,6 +767,7 @@
E6F485FE2C9E36B2000D914F /* PaymentDetailsViewControllerTests.swift in Sources */,
416E9E782C753B7900A0B917 /* ConnectComponentWebViewControllerTests.swift in Sources */,
410D0FD42C6D051B009B0E26 /* OpenAuthenticatedWebViewMessageHandlerTests.swift in Sources */,
E640C9D32CBF479E009D0C6E /* AuthenticatedWebViewManagerTests.swift in Sources */,
E65691202CA5248300E0DB00 /* AccountManagementViewControllerTests.swift in Sources */,
410D0FCA2C6CFE27009B0E26 /* OnLoadErrorMessageHandlerTests.swift in Sources */,
41BCCFF32C8B449800797E01 /* TestHelpers.swift in Sources */,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
//
// AuthenticatedWebViewError.swift
// StripeConnect
//
// Created by Mel Ludowise on 10/15/24.
//

enum AuthenticatedWebViewError: Int, Error {

// NOTE: These integer values should remain stable as they are used as
// error codes in error logging

/// ASWebAuthenticationSession could not be started
/// - Note: This can occur if the app is backgrounded when attempting to present the web view
case cannotStartSession = 0

/// There's no window on which to present
case notInViewHierarchy = 1

/// An ASWebAuthenticationSession is currently already being presented
case alreadyPresenting = 2
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
//
// AuthenticatedWebViewManager.swift
// StripeConnect
//
// Created by Mel Ludowise on 10/15/24.
//

import AuthenticationServices

/// Manages authenticated web views for a single component
class AuthenticatedWebViewManager: NSObject {
typealias SessionFactory = (
_ url: URL,
_ callbackURLScheme: String?,
_ completionHandler: @escaping ASWebAuthenticationSession.CompletionHandler
) -> ASWebAuthenticationSession

/// Used to dependency inject in tests and wrap `ASWebAuthenticationSession.init`
private let sessionFactory: SessionFactory

/// The currently presented auth session, if there is one
weak var authSession: ASWebAuthenticationSession?

init(sessionFactory: @escaping SessionFactory = ASWebAuthenticationSession.init) {
self.sessionFactory = sessionFactory
}

/// Returns the redirect URL or nil if the user cancelled the flow
@MainActor
func present(with url: URL, from view: UIView) async throws -> URL? {
guard authSession == nil else {
throw AuthenticatedWebViewError.alreadyPresenting
}
guard let window = view.window else {
throw AuthenticatedWebViewError.notInViewHierarchy
}

let presentationContextProvider = AuthenticatedWebViewPresentationContextProvider(window: window)

let returnUrl: URL? = try await withCheckedThrowingContinuation { continuation in
let authSession = sessionFactory(url, StripeConnectConstants.authenticatedWebViewReturnUrlScheme) { returnUrl, error in

if let authenticationSessionError = error as? ASWebAuthenticationSessionError,
authenticationSessionError.code == .canceledLogin {
// The user either selected "Cancel" in the initial modal
// prompting them to "Sign In" or they hit the "Cancel"
// button in presented browser view
continuation.resume(returning: nil)
return
} else if let error {
continuation.resume(throwing: error)
return
}

continuation.resume(returning: returnUrl)
}
authSession.presentationContextProvider = presentationContextProvider
self.authSession = authSession

guard authSession.canStart,
authSession.start() else {
continuation.resume(throwing: AuthenticatedWebViewError.cannotStartSession)
return
}
}

return returnUrl
}
}

// MARK: - ASWebAuthenticationPresentationContextProviding

private class AuthenticatedWebViewPresentationContextProvider: NSObject, ASWebAuthenticationPresentationContextProviding {
let window: UIWindow

init(window: UIWindow) {
self.window = window
}

func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor {
return window
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,4 +20,8 @@ enum StripeConnectConstants {
]

static let connectJSBaseURL = URL(string: "https://connect-js.stripe.com/v1.0/ios_webview.html")!

/// The authenticated web view will redirect back to the SDK when redirecting
/// to the URL scheme `stripe-connect://`
static let authenticatedWebViewReturnUrlScheme = "stripe-connect"
}
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@ class ConnectComponentWebViewController: ConnectWebViewController {
/// The current notification center instance
private let notificationCenter: NotificationCenter

/// Manages authenticated web views
private let authenticatedWebViewManager: AuthenticatedWebViewManager

private let setterMessageHandler: OnSetterFunctionCalledMessageHandler = .init()

private var didFailLoadWithError: (Error) -> Void
Expand All @@ -44,11 +47,13 @@ class ConnectComponentWebViewController: ConnectWebViewController {
didFailLoadWithError: @escaping (Error) -> Void,
// Should only be overridden for tests
notificationCenter: NotificationCenter = NotificationCenter.default,
webLocale: Locale = Locale.autoupdatingCurrent
webLocale: Locale = Locale.autoupdatingCurrent,
authenticatedWebViewManager: AuthenticatedWebViewManager = .init()
) {
self.componentManager = componentManager
self.notificationCenter = notificationCenter
self.webLocale = webLocale
self.authenticatedWebViewManager = authenticatedWebViewManager
self.didFailLoadWithError = didFailLoadWithError

let config = WKWebViewConfiguration()
Expand Down Expand Up @@ -93,14 +98,16 @@ class ConnectComponentWebViewController: ConnectWebViewController {
didFailLoadWithError: @escaping (Error) -> Void,
// Should only be overridden for tests
notificationCenter: NotificationCenter = NotificationCenter.default,
webLocale: Locale = Locale.autoupdatingCurrent) {
webLocale: Locale = Locale.autoupdatingCurrent,
authenticatedWebViewManager: AuthenticatedWebViewManager = .init()) {
self.init(componentManager: componentManager,
componentType: componentType,
loadContent: loadContent,
fetchInitProps: VoidPayload.init,
didFailLoadWithError: didFailLoadWithError,
notificationCenter: notificationCenter,
webLocale: webLocale)
webLocale: webLocale,
authenticatedWebViewManager: authenticatedWebViewManager)
}

required init?(coder: NSCoder) {
Expand Down Expand Up @@ -204,6 +211,9 @@ private extension ConnectComponentWebViewController {
addMessageHandler(AccountSessionClaimedMessageHandler{ _ in
// TODO: MXMOBILE-2491 Use this for analytics
})
addMessageHandler(OpenAuthenticatedWebViewMessageHandler { [weak self] payload in
self?.openAuthenticatedWebView(payload)
})
}

/// Adds NotificationCenter observers
Expand All @@ -229,4 +239,19 @@ private extension ConnectComponentWebViewController {
didFailLoadWithError(error)
activityIndicator.stopAnimating()
}

/// Opens the url in the given payload in an ASWebAuthenticationSession and sends the resulting redirect to
func openAuthenticatedWebView(_ payload: OpenAuthenticatedWebViewMessageHandler.Payload) {
Task { @MainActor in
do {
// TODO: MXMOBILE-2491 log `component.authenticated_web.*` analytic
let returnUrl = try await authenticatedWebViewManager.present(with: payload.url, from: view)

sendMessage(ReturnedFromAuthenticatedWebViewSender(payload: .init(url: returnUrl, id: payload.id)))
} catch {
// TODO: MXMOBILE-2491 log `component.authenticated_web.error` analytic
debugPrint("Error returning from authenticated web view: \(error)")
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,8 @@ class ScriptMessageHandlerWithReply<Payload: Decodable, Response: Encodable>: NS

return (response, nil)
} catch {
debugPrint("Error processing message: \(error.localizedDescription)")
return (nil, error.localizedDescription)
debugPrint("Error processing message: \((error as NSError).debugDescription)")
return (nil, (error as NSError).debugDescription)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,10 @@ import Foundation
/// Notifies that the user finished the flow within the `ASWebAuthenticationSession`
struct ReturnedFromAuthenticatedWebViewSender: MessageSender {
struct Payload: Codable, Equatable {
/// The return URL from the `ASWebAuthenticationSession` redirect. This value will be nil if the user canceled out fo the view
let url: String?
/// The return URL from the `ASWebAuthenticationSession` redirect. This value will be nil if the user canceled out of the view
let url: URL?
/// The unique identifier sent from the web view in `openAuthenticatedWebView`
let id: String
}
let name: String = "returnedFromAuthenticatedWebView"
let payload: Payload
Expand Down
Loading

0 comments on commit 2d1e81f

Please sign in to comment.