From 086e12e9c656aa4e66000f665747073527aa50b8 Mon Sep 17 00:00:00 2001 From: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Date: Wed, 19 Aug 2026 18:30:29 -0700 Subject: [PATCH] fix: Restore WebAuthn callbacks with unsupported Auth Tab browsers Fixes #7205 --- .../ui/platform/manager/IntentManagerImpl.kt | 16 +- .../platform/manager/IntentManagerImplTest.kt | 170 ++++++++++++++++++ 2 files changed, 183 insertions(+), 3 deletions(-) create mode 100644 ui/src/test/kotlin/com/bitwarden/ui/platform/manager/IntentManagerImplTest.kt diff --git a/ui/src/main/kotlin/com/bitwarden/ui/platform/manager/IntentManagerImpl.kt b/ui/src/main/kotlin/com/bitwarden/ui/platform/manager/IntentManagerImpl.kt index 1d0f3dd409e..6d7fbf030d4 100644 --- a/ui/src/main/kotlin/com/bitwarden/ui/platform/manager/IntentManagerImpl.kt +++ b/ui/src/main/kotlin/com/bitwarden/ui/platform/manager/IntentManagerImpl.kt @@ -98,9 +98,19 @@ internal class IntentManagerImpl( } } } else { - // Fall back to a Custom Tab. - Timber.d("Launching uri with CustomTabs fallback for $providerPackageName") - startCustomTabsActivity(uri = uri) + when (authTabData) { + is AuthTabData.CustomScheme -> { + Timber.d("Launching uri with CustomTabs fallback for $providerPackageName") + startCustomTabsActivity(uri = uri) + } + + is AuthTabData.HttpsScheme -> { + Timber.d( + "Launching uri with external browser fallback for $providerPackageName", + ) + launchUri(uri = uri) + } + } } } diff --git a/ui/src/test/kotlin/com/bitwarden/ui/platform/manager/IntentManagerImplTest.kt b/ui/src/test/kotlin/com/bitwarden/ui/platform/manager/IntentManagerImplTest.kt new file mode 100644 index 00000000000..cc5f5778002 --- /dev/null +++ b/ui/src/test/kotlin/com/bitwarden/ui/platform/manager/IntentManagerImplTest.kt @@ -0,0 +1,170 @@ +package com.bitwarden.ui.platform.manager + +import android.app.Activity +import android.content.ActivityNotFoundException +import android.content.Intent +import android.net.Uri +import androidx.activity.result.ActivityResultLauncher +import androidx.browser.auth.AuthTabIntent +import androidx.browser.customtabs.CustomTabsClient +import androidx.browser.customtabs.CustomTabsIntent +import com.bitwarden.core.data.manager.BuildInfoManager +import com.bitwarden.ui.platform.manager.intent.model.AuthTabData +import io.mockk.anyConstructed +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.mockkConstructor +import io.mockk.mockkStatic +import io.mockk.runs +import io.mockk.unmockkAll +import io.mockk.verify +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import java.time.Clock + +@Config(sdk = [Config.NEWEST_SDK]) +@RunWith(RobolectricTestRunner::class) +class IntentManagerImplTest { + + private val activity: Activity = mockk(relaxed = true) + private val launcher: ActivityResultLauncher = mockk(relaxed = true) + private val authTabIntent: AuthTabIntent = mockk(relaxed = true) + private val customTabsIntent: CustomTabsIntent = mockk(relaxed = true) + + private val intentManager = IntentManagerImpl( + activity = activity, + clock = Clock.systemUTC(), + buildInfoManager = mockk(), + ) + + @Before + fun setup() { + mockkStatic(CustomTabsClient::class) + mockkConstructor(AuthTabIntent.Builder::class) + mockkConstructor(CustomTabsIntent.Builder::class) + every { + CustomTabsClient.getPackageName(activity, null) + } returns PROVIDER_PACKAGE_NAME + every { + anyConstructed().build() + } returns authTabIntent + every { + anyConstructed().build() + } returns customTabsIntent + } + + @After + fun tearDown() { + unmockkAll() + } + + @Test + fun `startAuthTab with supported provider and HttpsScheme launches Auth Tab`() { + val uri = Uri.parse(CONNECTOR_URL) + val authTabData = AuthTabData.HttpsScheme( + host = CALLBACK_HOST, + path = CALLBACK_PATH, + ) + every { + CustomTabsClient.isAuthTabSupported(activity, PROVIDER_PACKAGE_NAME) + } returns true + + intentManager.startAuthTab(uri, authTabData, launcher) + + verify(exactly = 1) { + authTabIntent.launch(launcher, uri, CALLBACK_HOST, "\\$CALLBACK_PATH") + } + verify(exactly = 0) { activity.startActivity(any()) } + } + + @Test + fun `startAuthTab with supported provider and CustomScheme launches Auth Tab`() { + val uri = Uri.parse(CONNECTOR_URL) + val authTabData = AuthTabData.CustomScheme(callbackUrl = CUSTOM_CALLBACK_URL) + every { + CustomTabsClient.isAuthTabSupported(activity, PROVIDER_PACKAGE_NAME) + } returns true + + intentManager.startAuthTab(uri, authTabData, launcher) + + verify(exactly = 1) { + authTabIntent.launch(launcher, uri, authTabData.callbackScheme) + } + verify(exactly = 0) { activity.startActivity(any()) } + } + + @Test + fun `startAuthTab with unsupported provider and HttpsScheme launches browser intent`() { + val uri = Uri.parse(CONNECTOR_URL_WITH_UPPERCASE_SCHEME) + val startedIntent = mutableListOf() + every { activity.startActivity(capture(startedIntent)) } just runs + every { + CustomTabsClient.isAuthTabSupported(activity, PROVIDER_PACKAGE_NAME) + } returns false + + intentManager.startAuthTab( + uri = uri, + authTabData = AuthTabData.HttpsScheme( + host = CALLBACK_HOST, + path = CALLBACK_PATH, + ), + launcher = launcher, + ) + + assertEquals(Intent.ACTION_VIEW, startedIntent.single().action) + assertEquals(Uri.parse(CONNECTOR_URL), startedIntent.single().data) + verify(exactly = 0) { customTabsIntent.launchUrl(any(), any()) } + verify(exactly = 0) { launcher.launch(any()) } + } + + @Test + fun `startAuthTab with unsupported provider and CustomScheme launches Custom Tab`() { + val uri = Uri.parse(CONNECTOR_URL) + every { + CustomTabsClient.isAuthTabSupported(activity, PROVIDER_PACKAGE_NAME) + } returns false + + intentManager.startAuthTab( + uri = uri, + authTabData = AuthTabData.CustomScheme(callbackUrl = CUSTOM_CALLBACK_URL), + launcher = launcher, + ) + + verify(exactly = 1) { customTabsIntent.launchUrl(activity, uri) } + verify(exactly = 0) { activity.startActivity(any()) } + verify(exactly = 0) { launcher.launch(any()) } + } + + @Test + fun `startAuthTab with unsupported provider and unavailable browser does not crash`() { + every { activity.startActivity(any()) } throws ActivityNotFoundException() + every { + CustomTabsClient.isAuthTabSupported(activity, PROVIDER_PACKAGE_NAME) + } returns false + + intentManager.startAuthTab( + uri = Uri.parse(CONNECTOR_URL), + authTabData = AuthTabData.HttpsScheme( + host = CALLBACK_HOST, + path = CALLBACK_PATH, + ), + launcher = launcher, + ) + + verify(exactly = 0) { launcher.launch(any()) } + } +} + +private const val PROVIDER_PACKAGE_NAME: String = "com.example.browser" +private const val CONNECTOR_URL: String = "https://vault.bitwarden.com/webauthn-connector.html" +private const val CONNECTOR_URL_WITH_UPPERCASE_SCHEME: String = + "HTTPS://vault.bitwarden.com/webauthn-connector.html" +private const val CALLBACK_HOST: String = "bitwarden.com" +private const val CALLBACK_PATH: String = "webauthn-callback" +private const val CUSTOM_CALLBACK_URL: String = "bitwarden://webauthn-callback"