From e58c4706928b0006dd1854fb360247dbaea37db5 Mon Sep 17 00:00:00 2001 From: Michael Shafrir <45020849+mshafrir-stripe@users.noreply.github.com> Date: Fri, 13 Sep 2019 11:14:14 -0400 Subject: [PATCH] Fix out-of-band web payment authentication (#1537) When a user enters the in-app web payment authentication flow, is taken to another app to complete payment authentication out-of-band (i.e. a bank app), then returns to the WebView, make a request to the completion URL. This resolves the issue where the bank page loaded in the WebView doesn't detect that the user returned from the bank app. --- .../stripe/android/view/PaymentAuthWebView.kt | 51 +++++++++++++++---- .../view/PaymentAuthWebViewActivity.java | 7 ++- .../android/view/PaymentAuthWebViewTest.java | 49 +++++++++++++----- 3 files changed, 82 insertions(+), 25 deletions(-) diff --git a/stripe/src/main/java/com/stripe/android/view/PaymentAuthWebView.kt b/stripe/src/main/java/com/stripe/android/view/PaymentAuthWebView.kt index bd01d24bb92..6e8aa74de10 100644 --- a/stripe/src/main/java/com/stripe/android/view/PaymentAuthWebView.kt +++ b/stripe/src/main/java/com/stripe/android/view/PaymentAuthWebView.kt @@ -50,6 +50,8 @@ internal class PaymentAuthWebView : WebView { return webViewClient?.hasOpenedApp == true } + fun getCompletionUrl(): String? = webViewClient?.completionUrlParam + @SuppressLint("SetJavaScriptEnabled") private fun configureSettings() { settings.javaScriptEnabled = true @@ -63,8 +65,13 @@ internal class PaymentAuthWebView : WebView { private val clientSecret: String, returnUrl: String? ) : WebViewClient() { - private val returnUri: Uri? = if (returnUrl != null) Uri.parse(returnUrl) else null + // user-specified return URL + private val userReturnUri: Uri? = if (returnUrl != null) Uri.parse(returnUrl) else null + + var completionUrlParam: String? = null + private set + // true if another app was opened from this WebView var hasOpenedApp: Boolean = false private set @@ -80,8 +87,12 @@ internal class PaymentAuthWebView : WebView { } } - private fun isCompletionUrl(url: String): Boolean { - for (completionUrl in COMPLETION_URLS) { + private fun isAuthenticateUrl(url: String) = isWhiteListedUrl(url, AUTHENTICATE_URLS) + + private fun isCompletionUrl(url: String) = isWhiteListedUrl(url, COMPLETION_URLS) + + private fun isWhiteListedUrl(url: String, whitelistedUrls: Set): Boolean { + for (completionUrl in whitelistedUrls) { if (url.startsWith(completionUrl)) { return true } @@ -92,6 +103,8 @@ internal class PaymentAuthWebView : WebView { override fun shouldOverrideUrlLoading(view: WebView, urlString: String): Boolean { val uri = Uri.parse(urlString) + updateCompletionUrl(uri) + return if (isReturnUrl(uri)) { onAuthCompleted() true @@ -126,6 +139,18 @@ internal class PaymentAuthWebView : WebView { } } + private fun updateCompletionUrl(uri: Uri) { + val returnUrlParam = if (isAuthenticateUrl(uri.toString())) { + uri.getQueryParameter(PARAM_RETURN_URL) + } else { + null + } + + if (!returnUrlParam.isNullOrBlank()) { + completionUrlParam = returnUrlParam + } + } + @TargetApi(Build.VERSION_CODES.LOLLIPOP) override fun shouldOverrideUrlLoading( view: WebView, @@ -138,19 +163,19 @@ internal class PaymentAuthWebView : WebView { when { isPredefinedReturnUrl(uri) -> return true - // If the `returnUri` is known, look for URIs that match it. - returnUri != null -> - return returnUri.scheme != null && - returnUri.scheme == uri.scheme && - returnUri.host != null && - returnUri.host == uri.host + // If the `userReturnUri` is known, look for URIs that match it. + userReturnUri != null -> + return userReturnUri.scheme != null && + userReturnUri.scheme == uri.scheme && + userReturnUri.host != null && + userReturnUri.host == uri.host else -> { // Skip opaque (i.e. non-hierarchical) URIs if (uri.isOpaque) { return false } - // If the `returnUri` is unknown, look for URIs that contain a + // If the `userReturnUri` is unknown, look for URIs that contain a // `payment_intent_client_secret` or `setup_intent_client_secret` // query parameter, and check if its values matches the given `clientSecret` // as a query parameter. @@ -180,10 +205,16 @@ internal class PaymentAuthWebView : WebView { const val PARAM_PAYMENT_CLIENT_SECRET = "payment_intent_client_secret" const val PARAM_SETUP_CLIENT_SECRET = "setup_intent_client_secret" + private val AUTHENTICATE_URLS = setOf( + "https://hooks.stripe.com/three_d_secure/authenticate" + ) + private val COMPLETION_URLS = setOf( "https://hooks.stripe.com/redirect/complete/src_", "https://hooks.stripe.com/3d_secure/complete/tdsrc_" ) + + private const val PARAM_RETURN_URL = "return_url" } } } diff --git a/stripe/src/main/java/com/stripe/android/view/PaymentAuthWebViewActivity.java b/stripe/src/main/java/com/stripe/android/view/PaymentAuthWebViewActivity.java index 749bfd58335..8a1d13483cd 100644 --- a/stripe/src/main/java/com/stripe/android/view/PaymentAuthWebViewActivity.java +++ b/stripe/src/main/java/com/stripe/android/view/PaymentAuthWebViewActivity.java @@ -65,8 +65,11 @@ protected void onPostResume() { if (mWebView != null && mWebView.hasOpenedApp()) { // If another app was opened, assume it was a bank app where payment authentication - // was completed. Upon foregrounding this screen, finish the Activity. - finish(); + // was completed. Upon foregrounding this screen, load the completion URL. + final String completionUrl = mWebView.getCompletionUrl(); + if (completionUrl != null) { + mWebView.loadUrl(completionUrl); + } } } diff --git a/stripe/src/test/java/com/stripe/android/view/PaymentAuthWebViewTest.java b/stripe/src/test/java/com/stripe/android/view/PaymentAuthWebViewTest.java index a2d0ad2d314..0a3582a3513 100644 --- a/stripe/src/test/java/com/stripe/android/view/PaymentAuthWebViewTest.java +++ b/stripe/src/test/java/com/stripe/android/view/PaymentAuthWebViewTest.java @@ -19,6 +19,7 @@ import org.robolectric.RobolectricTestRunner; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; @@ -40,45 +41,44 @@ public void setup() { @Test public void shouldOverrideUrlLoading_withPaymentIntent_shouldSetResult() { - final String deepLink = "stripe://payment_intent_return?payment_intent=pi_123&" + + final String url = "stripe://payment_intent_return?payment_intent=pi_123&" + "payment_intent_client_secret=pi_123_secret_456&source_type=card"; final PaymentAuthWebView.PaymentAuthWebViewClient paymentAuthWebViewClient = createWebViewClient( "pi_123_secret_456", "stripe://payment_intent_return" ); - paymentAuthWebViewClient.shouldOverrideUrlLoading(mWebView, deepLink); + paymentAuthWebViewClient.shouldOverrideUrlLoading(mWebView, url); verify(mActivity).finish(); } @Test public void shouldOverrideUrlLoading_withSetupIntent_shouldSetResult() { - final String deepLink = "stripe://payment_auth?setup_intent=seti_1234" + + final String url = "stripe://payment_auth?setup_intent=seti_1234" + "&setup_intent_client_secret=seti_1234_secret_5678&source_type=card"; - final PaymentAuthWebView.PaymentAuthWebViewClient paymentAuthWebViewClient = createWebViewClient("seti_1234_secret_5678", "stripe://payment_auth"); - paymentAuthWebViewClient.shouldOverrideUrlLoading(mWebView, deepLink); + paymentAuthWebViewClient.shouldOverrideUrlLoading(mWebView, url); verify(mActivity).finish(); } @Test public void shouldOverrideUrlLoading_withoutReturnUrl_onPaymentIntentImplicitReturnUrl_shouldSetResult() { - final String deepLink = "stripe://payment_intent_return?payment_intent=pi_123&" + + final String url = "stripe://payment_intent_return?payment_intent=pi_123&" + "payment_intent_client_secret=pi_123_secret_456&source_type=card"; final PaymentAuthWebView.PaymentAuthWebViewClient paymentAuthWebViewClient = createWebViewClient("pi_123_secret_456"); - paymentAuthWebViewClient.shouldOverrideUrlLoading(mWebView, deepLink); + paymentAuthWebViewClient.shouldOverrideUrlLoading(mWebView, url); verify(mActivity).finish(); } @Test public void shouldOverrideUrlLoading_withoutReturnUrl_onSetupIntentImplicitReturnUrl_shouldSetResult() { - final String deepLink = "stripe://payment_auth?setup_intent=seti_1234" + + final String url = "stripe://payment_auth?setup_intent=seti_1234" + "&setup_intent_client_secret=seti_1234_secret_5678&source_type=card"; final PaymentAuthWebView.PaymentAuthWebViewClient paymentAuthWebViewClient = createWebViewClient("seti_1234_secret_5678"); - paymentAuthWebViewClient.shouldOverrideUrlLoading(mWebView, deepLink); + paymentAuthWebViewClient.shouldOverrideUrlLoading(mWebView, url); verify(mActivity).finish(); } @@ -120,19 +120,19 @@ public void onPageFinished_witRedirectCompleteUrl_shouldFinish() { @Test public void shouldOverrideUrlLoading_withOpaqueUri_shouldNotCrash() { - final String deepLink = "mailto:patrick@example.com?payment_intent=pi_123&" + + final String url = "mailto:patrick@example.com?payment_intent=pi_123&" + "payment_intent_client_secret=pi_123_secret_456&source_type=card"; final PaymentAuthWebView.PaymentAuthWebViewClient paymentAuthWebViewClient = createWebViewClient("pi_123_secret_456"); - paymentAuthWebViewClient.shouldOverrideUrlLoading(mWebView, deepLink); + paymentAuthWebViewClient.shouldOverrideUrlLoading(mWebView, url); } @Test public void shouldOverrideUrlLoading_withUnsupportedDeeplink_shouldFinish() { - final String deepLink = "deep://link"; + final String url = "deep://link"; final PaymentAuthWebView.PaymentAuthWebViewClient paymentAuthWebViewClient = createWebViewClient("pi_123_secret_456"); - paymentAuthWebViewClient.shouldOverrideUrlLoading(mWebView, deepLink); + paymentAuthWebViewClient.shouldOverrideUrlLoading(mWebView, url); verify(mActivity).finish(); } @@ -152,6 +152,29 @@ public void shouldOverrideUrlLoading_withIntentUri_shouldParseUri() { verify(mActivity).finish(); } + @Test + public void shouldOverrideUrlLoading_withAuthenticationUrlWithReturnUrlParam_shouldPopulateCompletionUrl() { + final String url = + "https://hooks.stripe.com/three_d_secure/authenticate?amount=1250&client_secret=src_client_secret_abc123&return_url=https%3A%2F%2Fhooks.stripe.com%2Fredirect%2Fcomplete%2Fsrc_X9Y8Z7%3Fclient_secret%3Dsrc_client_secret_abc123&source=src_X9Y8Z7&usage=single_use"; + final PaymentAuthWebView.PaymentAuthWebViewClient paymentAuthWebViewClient = + createWebViewClient("pi_123_secret_456"); + paymentAuthWebViewClient.shouldOverrideUrlLoading(mWebView, url); + assertEquals( + "https://hooks.stripe.com/redirect/complete/src_X9Y8Z7?client_secret=src_client_secret_abc123", + paymentAuthWebViewClient.getCompletionUrlParam() + ); + } + + @Test + public void shouldOverrideUrlLoading_withAuthenticationUrlWithoutReturnUrlParam_shouldNotPopulateCompletionUrl() { + final String url = + "https://hooks.stripe.com/three_d_secure/authenticate?amount=1250&client_secret=src_client_secret_abc123&return_url=&source=src_X9Y8Z7&usage=single_use"; + final PaymentAuthWebView.PaymentAuthWebViewClient paymentAuthWebViewClient = + createWebViewClient("pi_123_secret_456"); + paymentAuthWebViewClient.shouldOverrideUrlLoading(mWebView, url); + assertNull(paymentAuthWebViewClient.getCompletionUrlParam()); + } + @NonNull private PaymentAuthWebView.PaymentAuthWebViewClient createWebViewClient( @NonNull String clientSecret