disabledCustomTabsPac
return this;
}
- /**
- * Enable ephemeral browsing for the Custom Tab.
- * When enabled, the Custom Tab runs in an isolated session — cookies, cache,
- * history, and credentials are deleted when the tab closes.
- * Requires Chrome 136+ or a compatible browser. On unsupported browsers,
- * a warning is logged and a regular Custom Tab is used instead.
- * By default, ephemeral browsing is disabled.
- *
- * Warning: Ephemeral browsing support in Auth0.Android is still experimental
- * and can change in the future. Please test it thoroughly in all the targeted browsers
- * and OS variants and let us know your feedback.
- *
- * @return this same builder instance.
- */
- @ExperimentalAuth0Api
- @NonNull
- public Builder withEphemeralBrowsing() {
+ Builder withEphemeralBrowsing() {
this.ephemeralBrowsing = true;
return this;
}
+ Builder withAuthTab() {
+ this.authTab = true;
+ return this;
+ }
+
/**
* Sets the initial height for the Custom Tab to display as a bottom sheet.
* When set, the Custom Tab will appear as a bottom sheet instead of full screen.
@@ -457,9 +492,11 @@ public Builder withBackgroundInteractionEnabled(boolean enabled) {
public CustomTabsOptions build() {
return new CustomTabsOptions(showTitle, toolbarColor, browserPicker, disabledCustomTabsPackages,
initialHeight, activityHeightResizeBehavior, toolbarCornerRadius,
- initialWidth, sideSheetBreakpoint, backgroundInteractionEnabled, ephemeralBrowsing);
+ initialWidth, sideSheetBreakpoint, backgroundInteractionEnabled, ephemeralBrowsing,
+ authTab);
}
}
+
private int dpToPx(@NonNull Context context, int dp) {
final DisplayMetrics metrics = context.getResources().getDisplayMetrics();
return Math.round(dp * metrics.density);
diff --git a/auth0/src/main/java/com/auth0/android/provider/WebAuthProvider.kt b/auth0/src/main/java/com/auth0/android/provider/WebAuthProvider.kt
index 877c9d5b..702e2273 100644
--- a/auth0/src/main/java/com/auth0/android/provider/WebAuthProvider.kt
+++ b/auth0/src/main/java/com/auth0/android/provider/WebAuthProvider.kt
@@ -271,6 +271,7 @@ public object WebAuthProvider {
private var ctOptions: CustomTabsOptions = CustomTabsOptions.newBuilder().build()
private var federated: Boolean = false
private var launchAsTwa: Boolean = false
+ private var authTab: Boolean = false
private var customLogoutUrl: String? = null
/**
@@ -333,12 +334,35 @@ public object WebAuthProvider {
* Launches the Logout experience with a native feel (without address bar). For this to work,
* you have to setup the app as trusted following the steps mentioned [here](https://github.com/auth0/Auth0.Android/blob/main/EXAMPLES.md#trusted-web-activity-experimental).
*
+ * Note: [withAuthTab] and [withTrustedWebActivity] are mutually exclusive. If both are set,
+ * TWA takes precedence and Auth Tab will not be used. They rely on different underlying
+ * launch mechanisms and cannot be combined.
*/
public fun withTrustedWebActivity(): LogoutBuilder {
launchAsTwa = true
return this
}
+ /**
+ * Opts into using Auth Tab for the logout flow when the browser supports it.
+ * Auth Tab provides a dedicated, security-focused UI for OAuth flows with no address bar
+ * or share button. Falls back to a regular Custom Tab on browsers that do not support it.
+ *
+ * **Warning:** Auth Tab support in Auth0.Android is still experimental and can change in
+ * the future.
+ *
+ * Note: [withAuthTab] and [withTrustedWebActivity] are mutually exclusive. If both are set,
+ * TWA takes precedence and Auth Tab will not be used. They rely on different underlying
+ * launch mechanisms and cannot be combined.
+ *
+ * @return the current builder instance
+ */
+ @ExperimentalAuth0Api
+ public fun withAuthTab(): LogoutBuilder {
+ authTab = true
+ return this
+ }
+
/**
* Specifies a custom Logout URL to use for this logout request, overriding the default
* generated from the Auth0 domain (account.logoutUrl).
@@ -385,7 +409,8 @@ public object WebAuthProvider {
private fun startInternal(context: Context, callback: Callback) {
resetManagerInstance()
- if (!ctOptions.hasCompatibleBrowser(context.packageManager)) {
+ val effectiveCtOptions = if (authTab) ctOptions.copyWithAuthTab() else ctOptions
+ if (!effectiveCtOptions.hasCompatibleBrowser(context.packageManager)) {
val ex = AuthenticationException(
"a0.browser_not_available",
"No compatible Browser application is installed."
@@ -404,7 +429,7 @@ public object WebAuthProvider {
account,
callback,
returnToUrl!!,
- ctOptions,
+ effectiveCtOptions,
federated,
launchAsTwa,
customLogoutUrl
@@ -456,6 +481,7 @@ public object WebAuthProvider {
private var leeway: Int? = null
private var launchAsTwa: Boolean = false
private var ephemeralBrowsing: Boolean = false
+ private var authTab: Boolean = false
private var customAuthorizeUrl: String? = null
/**
@@ -664,6 +690,9 @@ public object WebAuthProvider {
* Launches the Login experience with a native feel (without address bar). For this to work,
* you have to setup the app as trusted following the steps mentioned [here](https://github.com/auth0/Auth0.Android/blob/main/EXAMPLES.md#trusted-web-activity-experimental).
*
+ * Note: [withAuthTab] and [withTrustedWebActivity] are mutually exclusive. If both are set,
+ * TWA takes precedence and Auth Tab will not be used. They rely on different underlying
+ * launch mechanisms and cannot be combined.
*/
public fun withTrustedWebActivity(): Builder {
launchAsTwa = true
@@ -689,6 +718,26 @@ public object WebAuthProvider {
return this
}
+ /**
+ * Opts into using Auth Tab for the authentication flow when the browser supports it.
+ * Auth Tab provides a dedicated, security-focused UI for OAuth flows with no address bar
+ * or share button. Falls back to a regular Custom Tab on browsers that do not support it.
+ *
+ * **Warning:** Auth Tab support in Auth0.Android is still experimental and can change in
+ * the future.
+ *
+ * Note: [withAuthTab] and [withTrustedWebActivity] are mutually exclusive. If both are set,
+ * TWA takes precedence and Auth Tab will not be used. They rely on different underlying
+ * launch mechanisms and cannot be combined.
+ *
+ * @return the current builder instance
+ */
+ @ExperimentalAuth0Api
+ public fun withAuthTab(): Builder {
+ authTab = true
+ return this
+ }
+
/**
* Specifies a custom Authorize URL to use for this login request, overriding the default
* generated from the Auth0 domain (account.authorizeUrl).
@@ -792,11 +841,9 @@ public object WebAuthProvider {
values[OAuthManager.KEY_INVITATION] = invitationId
}
- val effectiveCtOptions = if (ephemeralBrowsing) {
- ctOptions.copyWithEphemeralBrowsing()
- } else {
- ctOptions
- }
+ var effectiveCtOptions = ctOptions
+ if (ephemeralBrowsing) effectiveCtOptions = effectiveCtOptions.copyWithEphemeralBrowsing()
+ if (authTab) effectiveCtOptions = effectiveCtOptions.copyWithAuthTab()
val manager = OAuthManager(
account, callback, values, effectiveCtOptions, launchAsTwa,
diff --git a/auth0/src/test/java/com/auth0/android/provider/AuthTabResultHandlerTest.kt b/auth0/src/test/java/com/auth0/android/provider/AuthTabResultHandlerTest.kt
new file mode 100644
index 00000000..4e23eb96
--- /dev/null
+++ b/auth0/src/test/java/com/auth0/android/provider/AuthTabResultHandlerTest.kt
@@ -0,0 +1,100 @@
+package com.auth0.android.provider
+
+import android.app.Activity
+import android.net.Uri
+import androidx.browser.auth.AuthTabIntent
+import com.auth0.android.authentication.AuthenticationException
+import org.hamcrest.CoreMatchers.`is`
+import org.hamcrest.MatcherAssert.assertThat
+import org.junit.Assert.assertNull
+import org.junit.Assert.fail
+import org.junit.Test
+import org.mockito.Mockito.mock
+
+public class AuthTabResultHandlerTest {
+
+ @Test
+ public fun shouldCallOnSuccessWithUriOnResultOk() {
+ val uri = mock(Uri::class.java)
+ var deliveredUri: Uri? = null
+ val handler = AuthTabResultHandler(
+ onSuccess = { deliveredUri = it },
+ onFailure = { fail("unexpected failure") },
+ onCancel = { fail("unexpected cancel") }
+ )
+
+ handler.handle(Activity.RESULT_OK, uri)
+
+ assertThat(deliveredUri, `is`(uri))
+ }
+
+ @Test
+ public fun shouldCallOnSuccessWithNullUriWhenResultOkHasNoUri() {
+ var deliveredUri: Uri? = mock(Uri::class.java)
+ val handler = AuthTabResultHandler(
+ onSuccess = { deliveredUri = it },
+ onFailure = { fail("unexpected failure") },
+ onCancel = { fail("unexpected cancel") }
+ )
+
+ handler.handle(Activity.RESULT_OK, null)
+
+ assertNull(deliveredUri)
+ }
+
+ @Test
+ public fun shouldCallOnCancelOnResultCanceled() {
+ var cancelCalled = false
+ val handler = AuthTabResultHandler(
+ onSuccess = { fail("unexpected success") },
+ onFailure = { fail("unexpected failure") },
+ onCancel = { cancelCalled = true }
+ )
+
+ handler.handle(Activity.RESULT_CANCELED, null)
+
+ assertThat(cancelCalled, `is`(true))
+ }
+
+ @Test
+ public fun shouldCallOnFailureOnResultVerificationFailed() {
+ var error: AuthenticationException? = null
+ val handler = AuthTabResultHandler(
+ onSuccess = { fail("unexpected success") },
+ onFailure = { error = it },
+ onCancel = { fail("unexpected cancel") }
+ )
+
+ handler.handle(AuthTabIntent.RESULT_VERIFICATION_FAILED, null)
+
+ assertThat(error?.getCode(), `is`("a0.auth_tab_verification_failed"))
+ }
+
+ @Test
+ public fun shouldCallOnFailureOnResultVerificationTimedOut() {
+ var error: AuthenticationException? = null
+ val handler = AuthTabResultHandler(
+ onSuccess = { fail("unexpected success") },
+ onFailure = { error = it },
+ onCancel = { fail("unexpected cancel") }
+ )
+
+ handler.handle(AuthTabIntent.RESULT_VERIFICATION_TIMED_OUT, null)
+
+ assertThat(error?.getCode(), `is`("a0.auth_tab_verification_failed"))
+ }
+
+ @Test
+ public fun shouldCallOnCancelOnUnknownResultCode() {
+ var cancelCalled = false
+ val handler = AuthTabResultHandler(
+ onSuccess = { fail("unexpected success") },
+ onFailure = { fail("unexpected failure") },
+ onCancel = { cancelCalled = true }
+ )
+
+ handler.handle(AuthTabIntent.RESULT_UNKNOWN_CODE, null)
+
+ assertThat(cancelCalled, `is`(true))
+ }
+}
diff --git a/auth0/src/test/java/com/auth0/android/provider/AuthenticationActivityTest.kt b/auth0/src/test/java/com/auth0/android/provider/AuthenticationActivityTest.kt
index 471dbda1..8e8d5999 100644
--- a/auth0/src/test/java/com/auth0/android/provider/AuthenticationActivityTest.kt
+++ b/auth0/src/test/java/com/auth0/android/provider/AuthenticationActivityTest.kt
@@ -5,6 +5,7 @@ import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.os.Parcelable
+import androidx.browser.auth.AuthTabIntent
import androidx.test.espresso.intent.matcher.IntentMatchers
import com.auth0.android.authentication.AuthenticationException
import com.auth0.android.callback.RunnableTask
@@ -306,6 +307,60 @@ public class AuthenticationActivityTest {
activityController.destroy()
}
+ @Test
+ public fun shouldDeliverResultWhenAuthTabSucceeds() {
+ AuthenticationActivity.authenticateUsingBrowser(
+ callerActivity, uri, false, customTabsOptions
+ )
+ Mockito.verify(callerActivity).startActivity(intentCaptor.capture())
+ createActivity(intentCaptor.value)
+ activityController.create().start().resume()
+
+ activity.authTabResultHandler.handle(Activity.RESULT_OK, resultUri)
+
+ MatcherAssert.assertThat(activity.deliveredIntent, Is.`is`(Matchers.notNullValue()))
+ MatcherAssert.assertThat(activity.deliveredIntent!!.data, Is.`is`(resultUri))
+ MatcherAssert.assertThat(activity.isFinishing, Is.`is`(true))
+ activityController.destroy()
+ }
+
+ @Test
+ public fun shouldDeliverCanceledWhenAuthTabCanceled() {
+ AuthenticationActivity.authenticateUsingBrowser(
+ callerActivity, uri, false, customTabsOptions
+ )
+ Mockito.verify(callerActivity).startActivity(intentCaptor.capture())
+ createActivity(intentCaptor.value)
+ activityController.create().start().resume()
+
+ activity.authTabResultHandler.handle(Activity.RESULT_CANCELED, null)
+
+ MatcherAssert.assertThat(activity.deliveredIntent, Is.`is`(Matchers.notNullValue()))
+ MatcherAssert.assertThat(activity.deliveredIntent!!.data, Is.`is`(Matchers.nullValue()))
+ MatcherAssert.assertThat(activity.isFinishing, Is.`is`(true))
+ activityController.destroy()
+ }
+
+ @Test
+ public fun shouldDeliverFailureWhenAuthTabVerificationFails() {
+ AuthenticationActivity.authenticateUsingBrowser(
+ callerActivity, uri, false, customTabsOptions
+ )
+ Mockito.verify(callerActivity).startActivity(intentCaptor.capture())
+ createActivity(intentCaptor.value)
+ activityController.create().start().resume()
+
+ activity.authTabResultHandler.handle(AuthTabIntent.RESULT_VERIFICATION_FAILED, null)
+
+ MatcherAssert.assertThat(activity.deliveredException, Is.`is`(Matchers.notNullValue()))
+ MatcherAssert.assertThat(
+ activity.deliveredException!!.getCode(),
+ Is.`is`("a0.auth_tab_verification_failed")
+ )
+ MatcherAssert.assertThat(activity.isFinishing, Is.`is`(true))
+ activityController.destroy()
+ }
+
private fun recreateAndCallNewIntent(data: Intent) {
val outState = Bundle()
activityController.saveInstanceState(outState)
diff --git a/auth0/src/test/java/com/auth0/android/provider/CustomTabsControllerTest.java b/auth0/src/test/java/com/auth0/android/provider/CustomTabsControllerTest.java
index 7d34e776..172f41c6 100644
--- a/auth0/src/test/java/com/auth0/android/provider/CustomTabsControllerTest.java
+++ b/auth0/src/test/java/com/auth0/android/provider/CustomTabsControllerTest.java
@@ -21,6 +21,8 @@
import static org.mockito.Mockito.when;
import static org.mockito.Mockito.withSettings;
+import androidx.activity.result.ActivityResultLauncher;
+
import android.app.Activity;
import android.content.ActivityNotFoundException;
import android.content.ComponentName;
@@ -43,12 +45,15 @@
import com.google.androidbrowserhelper.trusted.TwaLauncher;
import com.google.androidbrowserhelper.trusted.splashscreens.SplashScreenStrategy;
+import org.junit.After;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Captor;
import org.mockito.Mock;
+import org.mockito.MockedStatic;
+import org.mockito.Mockito;
import org.mockito.MockitoAnnotations;
import org.mockito.quality.Strictness;
import org.mockito.stubbing.Answer;
@@ -56,14 +61,18 @@
import org.robolectric.RobolectricTestRunner;
import java.util.List;
+import java.util.concurrent.atomic.AtomicReference;
@RunWith(RobolectricTestRunner.class)
public class CustomTabsControllerTest {
private static final String DEFAULT_BROWSER_PACKAGE = "com.auth0.browser";
private static final long MAX_TEST_WAIT_TIME_MS = 2000;
+ private static final String AUTH_URL_WITH_REDIRECT =
+ "https://example.auth0.com/authorize?redirect_uri=myapp%3A%2F%2Fcallback";
private Context context;
+ private MockedStatic customTabsClientMock;
@Mock
private Uri uri;
@Mock
@@ -82,6 +91,14 @@ public class CustomTabsControllerTest {
private CustomTabsController controller;
+ @After
+ public void tearDown() {
+ if (customTabsClientMock != null) {
+ customTabsClientMock.close();
+ customTabsClientMock = null;
+ }
+ }
+
@Before
public void setUp() {
MockitoAnnotations.openMocks(this);
@@ -104,7 +121,7 @@ public void backgroundThread(@NonNull Runnable runnable) {
when(browserPicker.getBestBrowserPackage(context.getPackageManager())).thenReturn(DEFAULT_BROWSER_PACKAGE);
CustomTabsOptions ctOptions = CustomTabsOptions.newBuilder().withBrowserPicker(browserPicker).build();
- controller = new CustomTabsController(context, ctOptions, twaLauncher);
+ controller = new CustomTabsController(context, ctOptions, twaLauncher, null);
}
@Test
@@ -195,7 +212,7 @@ public void shouldLaunchUriUsingFallbackWhenNoCompatibleBrowserIsAvailable() {
BrowserPicker browserPicker = mock(BrowserPicker.class);
when(browserPicker.getBestBrowserPackage(context.getPackageManager())).thenReturn(null);
CustomTabsOptions ctOptions = CustomTabsOptions.newBuilder().withBrowserPicker(browserPicker).build();
- CustomTabsController controller = new CustomTabsController(context, ctOptions, twaLauncher);
+ CustomTabsController controller = new CustomTabsController(context, ctOptions, twaLauncher, null);
controller.launchUri(uri, false, mockThreadSwitcher, null);
verify(context, timeout(MAX_TEST_WAIT_TIME_MS)).startActivity(launchIntentCaptor.capture());
@@ -217,7 +234,7 @@ public void shouldBindAndLaunchUriWithCustomization() throws Exception {
.withToolbarColor(android.R.color.black)
.withBrowserPicker(browserPicker)
.build();
- CustomTabsController controller = new CustomTabsController(context, ctOptions, twaLauncher);
+ CustomTabsController controller = new CustomTabsController(context, ctOptions, twaLauncher, null);
bindService(controller, true);
connectBoundService();
@@ -247,7 +264,7 @@ public void shouldBindAndLaunchUriWithCustomizationTwa() throws Exception {
.withToolbarColor(android.R.color.black)
.withBrowserPicker(browserPicker)
.build();
- CustomTabsController controller = new CustomTabsController(context, ctOptions, twaLauncher);
+ CustomTabsController controller = new CustomTabsController(context, ctOptions, twaLauncher, null);
bindService(controller, true);
controller.launchUri(uri, true, mockThreadSwitcher, null);
@@ -339,6 +356,176 @@ public void shouldThrowExceptionIfFailedToLaunchBecauseOfException() {
});
}
+ // --- Auth Tab ---
+
+ @Test
+ public void shouldLaunchAsAuthTabWhenSupportedByBrowser() throws Exception {
+ customTabsClientMock = Mockito.mockStatic(CustomTabsClient.class);
+ customTabsClientMock.when(() ->
+ CustomTabsClient.isAuthTabSupported(any(), eq(DEFAULT_BROWSER_PACKAGE))
+ ).thenReturn(true);
+
+ @SuppressWarnings("unchecked")
+ ActivityResultLauncher mockAuthTabLauncher = mock(ActivityResultLauncher.class);
+
+ BrowserPicker browserPicker = mock(BrowserPicker.class);
+ when(browserPicker.getBestBrowserPackage(context.getPackageManager())).thenReturn(DEFAULT_BROWSER_PACKAGE);
+ CustomTabsOptions ctOptions = CustomTabsOptions.newBuilder()
+ .withBrowserPicker(browserPicker)
+ .withAuthTab()
+ .build();
+
+ CustomTabsController authTabController =
+ new CustomTabsController(context, ctOptions, twaLauncher, mockAuthTabLauncher);
+
+ Uri authorizeUri = Uri.parse(AUTH_URL_WITH_REDIRECT);
+ authTabController.launchUri(authorizeUri, false, mockThreadSwitcher, null);
+
+ verify(mockAuthTabLauncher, timeout(MAX_TEST_WAIT_TIME_MS)).launch(any(Intent.class));
+ verify(context, never()).startActivity(any(Intent.class));
+ }
+
+ @Test
+ public void shouldFallbackToCustomTabWhenAuthTabNotSupportedByBrowser() {
+ customTabsClientMock = Mockito.mockStatic(CustomTabsClient.class);
+ customTabsClientMock.when(() ->
+ CustomTabsClient.isAuthTabSupported(any(), eq(DEFAULT_BROWSER_PACKAGE))
+ ).thenReturn(false);
+
+ @SuppressWarnings("unchecked")
+ ActivityResultLauncher mockAuthTabLauncher = mock(ActivityResultLauncher.class);
+
+ BrowserPicker browserPicker = mock(BrowserPicker.class);
+ when(browserPicker.getBestBrowserPackage(context.getPackageManager())).thenReturn(DEFAULT_BROWSER_PACKAGE);
+ CustomTabsOptions ctOptions = CustomTabsOptions.newBuilder()
+ .withBrowserPicker(browserPicker)
+ .withAuthTab()
+ .build();
+
+ CustomTabsController authTabController =
+ new CustomTabsController(context, ctOptions, twaLauncher, mockAuthTabLauncher);
+ doReturn(false).when(context).bindService(any(), any(), anyInt());
+
+ Uri authorizeUri = Uri.parse(AUTH_URL_WITH_REDIRECT);
+ authTabController.launchUri(authorizeUri, false, mockThreadSwitcher, null);
+
+ verify(context, timeout(MAX_TEST_WAIT_TIME_MS)).startActivity(launchIntentCaptor.capture());
+ verify(mockAuthTabLauncher, never()).launch(any(Intent.class));
+ }
+
+ @Test
+ public void shouldFallbackToCustomTabWhenNoPreferredBrowserForAuthTab() {
+ @SuppressWarnings("unchecked")
+ ActivityResultLauncher mockAuthTabLauncher = mock(ActivityResultLauncher.class);
+
+ BrowserPicker browserPicker = mock(BrowserPicker.class);
+ when(browserPicker.getBestBrowserPackage(context.getPackageManager())).thenReturn(null);
+ CustomTabsOptions ctOptions = CustomTabsOptions.newBuilder()
+ .withBrowserPicker(browserPicker)
+ .withAuthTab()
+ .build();
+
+ CustomTabsController authTabController =
+ new CustomTabsController(context, ctOptions, twaLauncher, mockAuthTabLauncher);
+
+ Uri authorizeUri = Uri.parse(AUTH_URL_WITH_REDIRECT);
+ authTabController.launchUri(authorizeUri, false, mockThreadSwitcher, null);
+
+ verify(context, timeout(MAX_TEST_WAIT_TIME_MS)).startActivity(launchIntentCaptor.capture());
+ verify(mockAuthTabLauncher, never()).launch(any(Intent.class));
+ }
+
+ @Test
+ public void shouldFallbackToCustomTabWhenAuthTabLauncherIsNull() {
+ customTabsClientMock = Mockito.mockStatic(CustomTabsClient.class);
+ customTabsClientMock.when(() ->
+ CustomTabsClient.isAuthTabSupported(any(), eq(DEFAULT_BROWSER_PACKAGE))
+ ).thenReturn(true);
+
+ BrowserPicker browserPicker = mock(BrowserPicker.class);
+ when(browserPicker.getBestBrowserPackage(context.getPackageManager())).thenReturn(DEFAULT_BROWSER_PACKAGE);
+ CustomTabsOptions ctOptions = CustomTabsOptions.newBuilder()
+ .withBrowserPicker(browserPicker)
+ .withAuthTab()
+ .build();
+
+ // Pass null authTabLauncher — simulates launcher not being registered
+ CustomTabsController authTabController =
+ new CustomTabsController(context, ctOptions, twaLauncher, null);
+ doReturn(false).when(context).bindService(any(), any(), anyInt());
+
+ Uri authorizeUri = Uri.parse(AUTH_URL_WITH_REDIRECT);
+ authTabController.launchUri(authorizeUri, false, mockThreadSwitcher, null);
+
+ verify(context, timeout(MAX_TEST_WAIT_TIME_MS)).startActivity(launchIntentCaptor.capture());
+ }
+
+ @Test
+ public void shouldReturnErrorWhenRedirectUriIsMissing() {
+ customTabsClientMock = Mockito.mockStatic(CustomTabsClient.class);
+ customTabsClientMock.when(() ->
+ CustomTabsClient.isAuthTabSupported(any(), eq(DEFAULT_BROWSER_PACKAGE))
+ ).thenReturn(true);
+
+ @SuppressWarnings("unchecked")
+ ActivityResultLauncher mockAuthTabLauncher = mock(ActivityResultLauncher.class);
+
+ BrowserPicker browserPicker = mock(BrowserPicker.class);
+ when(browserPicker.getBestBrowserPackage(context.getPackageManager())).thenReturn(DEFAULT_BROWSER_PACKAGE);
+ CustomTabsOptions ctOptions = CustomTabsOptions.newBuilder()
+ .withBrowserPicker(browserPicker)
+ .withAuthTab()
+ .build();
+
+ CustomTabsController authTabController =
+ new CustomTabsController(context, ctOptions, twaLauncher, mockAuthTabLauncher);
+
+ // No redirect_uri query parameter at all
+ Uri authorizeUri = Uri.parse("https://example.auth0.com/authorize?response_type=code");
+ AtomicReference capturedException = new AtomicReference<>();
+ authTabController.launchUri(authorizeUri, false, mockThreadSwitcher, capturedException::set);
+
+ verify(mockThreadSwitcher, timeout(MAX_TEST_WAIT_TIME_MS)).mainThread(any());
+ assertThat(capturedException.get(), is(notNullValue()));
+ assertThat(capturedException.get().getCode(), is("a0.invalid_authorize_url"));
+ assertThat(capturedException.get().getDescription(), is("Could not determine redirect URI from authorize URL"));
+ verify(context, never()).startActivity(any(Intent.class));
+ verify(mockAuthTabLauncher, never()).launch(any(Intent.class));
+ }
+
+ @Test
+ public void shouldReturnErrorWhenRedirectUriHasNoScheme() {
+ customTabsClientMock = Mockito.mockStatic(CustomTabsClient.class);
+ customTabsClientMock.when(() ->
+ CustomTabsClient.isAuthTabSupported(any(), eq(DEFAULT_BROWSER_PACKAGE))
+ ).thenReturn(true);
+
+ @SuppressWarnings("unchecked")
+ ActivityResultLauncher mockAuthTabLauncher = mock(ActivityResultLauncher.class);
+
+ BrowserPicker browserPicker = mock(BrowserPicker.class);
+ when(browserPicker.getBestBrowserPackage(context.getPackageManager())).thenReturn(DEFAULT_BROWSER_PACKAGE);
+ CustomTabsOptions ctOptions = CustomTabsOptions.newBuilder()
+ .withBrowserPicker(browserPicker)
+ .withAuthTab()
+ .build();
+
+ CustomTabsController authTabController =
+ new CustomTabsController(context, ctOptions, twaLauncher, mockAuthTabLauncher);
+
+ // redirect_uri query param present but has no scheme — Uri.parse("callback").getScheme() == null
+ Uri authorizeUri = Uri.parse("https://example.auth0.com/authorize?redirect_uri=callback");
+ AtomicReference capturedException = new AtomicReference<>();
+ authTabController.launchUri(authorizeUri, false, mockThreadSwitcher, capturedException::set);
+
+ verify(mockThreadSwitcher, timeout(MAX_TEST_WAIT_TIME_MS)).mainThread(any());
+ assertThat(capturedException.get(), is(notNullValue()));
+ assertThat(capturedException.get().getCode(), is("a0.invalid_authorize_url"));
+ assertThat(capturedException.get().getDescription(), is("Could not determine scheme from redirect URI: callback"));
+ verify(context, never()).startActivity(any(Intent.class));
+ verify(mockAuthTabLauncher, never()).launch(any(Intent.class));
+ }
+
//Helper Methods
@SuppressWarnings("WrongConstant")
diff --git a/auth0/src/test/java/com/auth0/android/provider/CustomTabsOptionsTest.java b/auth0/src/test/java/com/auth0/android/provider/CustomTabsOptionsTest.java
index 59344346..aa2808f1 100644
--- a/auth0/src/test/java/com/auth0/android/provider/CustomTabsOptionsTest.java
+++ b/auth0/src/test/java/com/auth0/android/provider/CustomTabsOptionsTest.java
@@ -664,6 +664,74 @@ public void shouldNotSetPartialOptionsWhenDisabledBrowser() {
assertEquals(intentNoExtras.getAction(), "android.intent.action.VIEW");
}
+ // --- Auth Tab ---
+
+ @Test
+ public void shouldHaveAuthTabDisabledByDefault() {
+ CustomTabsOptions options = CustomTabsOptions.newBuilder().build();
+ assertThat(options.isAuthTab(), is(false));
+
+ Parcel parcel = Parcel.obtain();
+ options.writeToParcel(parcel, 0);
+ parcel.setDataPosition(0);
+ CustomTabsOptions parceledOptions = CustomTabsOptions.CREATOR.createFromParcel(parcel);
+ assertThat(parceledOptions.isAuthTab(), is(false));
+ }
+
+ @Test
+ public void shouldSetAuthTab() {
+ CustomTabsOptions options = CustomTabsOptions.newBuilder()
+ .withAuthTab()
+ .build();
+ assertThat(options.isAuthTab(), is(true));
+
+ Parcel parcel = Parcel.obtain();
+ options.writeToParcel(parcel, 0);
+ parcel.setDataPosition(0);
+ CustomTabsOptions parceledOptions = CustomTabsOptions.CREATOR.createFromParcel(parcel);
+ assertThat(parceledOptions.isAuthTab(), is(true));
+ }
+
+ @Test
+ public void shouldCopyWithEphemeralBrowsingPreservesAuthTab() {
+ CustomTabsOptions options = CustomTabsOptions.newBuilder()
+ .withAuthTab().build();
+ assertThat(options.isAuthTab(), is(true));
+
+ CustomTabsOptions copied = options.copyWithEphemeralBrowsing();
+ assertThat(copied.isAuthTab(), is(true));
+ }
+
+ @Test
+ public void shouldCopyWithAuthTab() {
+ CustomTabsOptions options = CustomTabsOptions.newBuilder().build();
+ assertThat(options.isAuthTab(), is(false));
+
+ CustomTabsOptions copied = options.copyWithAuthTab();
+ assertThat(copied.isAuthTab(), is(true));
+ }
+
+ @Test
+ public void shouldCopyWithAuthTabPreservesEphemeralBrowsing() {
+ CustomTabsOptions options = CustomTabsOptions.newBuilder()
+ .withEphemeralBrowsing().build();
+
+ CustomTabsOptions copied = options.copyWithAuthTab();
+ assertThat(copied.isAuthTab(), is(true));
+ }
+
+ @Test
+ public void shouldToBuilderPreserveAllFields() {
+ CustomTabsOptions original = CustomTabsOptions.newBuilder()
+ .showTitle(true)
+ .withToolbarColor(android.R.color.black)
+ .withEphemeralBrowsing().withAuthTab().build();
+
+ CustomTabsOptions rebuilt = original.toBuilder().build();
+
+ assertThat(rebuilt.isAuthTab(), is(true));
+ }
+
/**
* Helper to check if a log message containing the given text was emitted.
*/
diff --git a/auth0/src/test/java/com/auth0/android/provider/WebAuthProviderTest.kt b/auth0/src/test/java/com/auth0/android/provider/WebAuthProviderTest.kt
index 2ff5bdc8..4e9a86b1 100644
--- a/auth0/src/test/java/com/auth0/android/provider/WebAuthProviderTest.kt
+++ b/auth0/src/test/java/com/auth0/android/provider/WebAuthProviderTest.kt
@@ -3070,6 +3070,53 @@ public class WebAuthProviderTest {
verify(options, Mockito.never()).copyWithEphemeralBrowsing()
}
+ @Test
+ public fun shouldStartLoginWithAuthTab() {
+ val options = Mockito.mock(CustomTabsOptions::class.java)
+ val authTabOptions = Mockito.mock(CustomTabsOptions::class.java)
+ `when`(options.hasCompatibleBrowser(activity.packageManager)).thenReturn(true)
+ `when`(options.copyWithAuthTab()).thenReturn(authTabOptions)
+ login(account)
+ .withCustomTabsOptions(options)
+ .withAuthTab()
+ .start(activity, callback)
+ verify(options).copyWithAuthTab()
+ }
+
+ @Test
+ public fun shouldNotSetAuthTabByDefault() {
+ val options = Mockito.mock(CustomTabsOptions::class.java)
+ `when`(options.hasCompatibleBrowser(activity.packageManager)).thenReturn(true)
+ login(account)
+ .withCustomTabsOptions(options)
+ .start(activity, callback)
+ verify(options, Mockito.never()).copyWithAuthTab()
+ }
+
+ @Test
+ public fun shouldStartLogoutWithAuthTab() {
+ val options = Mockito.mock(CustomTabsOptions::class.java)
+ val authTabOptions = Mockito.mock(CustomTabsOptions::class.java)
+ `when`(options.hasCompatibleBrowser(activity.packageManager)).thenReturn(true)
+ `when`(options.copyWithAuthTab()).thenReturn(authTabOptions)
+ `when`(authTabOptions.hasCompatibleBrowser(activity.packageManager)).thenReturn(true)
+ logout(account)
+ .withCustomTabsOptions(options)
+ .withAuthTab()
+ .start(activity, voidCallback)
+ verify(options).copyWithAuthTab()
+ }
+
+ @Test
+ public fun shouldNotSetAuthTabByDefaultOnLogout() {
+ val options = Mockito.mock(CustomTabsOptions::class.java)
+ `when`(options.hasCompatibleBrowser(activity.packageManager)).thenReturn(true)
+ logout(account)
+ .withCustomTabsOptions(options)
+ .start(activity, voidCallback)
+ verify(options, Mockito.never()).copyWithAuthTab()
+ }
+
// --- LifecycleAwareCallback tests ---
@Test