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() {
+ this.ephemeralBrowsing = true;
+ return this;
+ }
+
/**
* Create a new CustomTabsOptions instance with the customization settings.
*
@@ -219,7 +271,7 @@ public Builder withDisabledCustomTabsPackages(List disabledCustomTabsPac
*/
@NonNull
public CustomTabsOptions build() {
- return new CustomTabsOptions(showTitle, toolbarColor, browserPicker, disabledCustomTabsPackages);
+ return new CustomTabsOptions(showTitle, toolbarColor, browserPicker, disabledCustomTabsPackages, ephemeralBrowsing);
}
}
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 75832726..70cf647f 100644
--- a/auth0/src/main/java/com/auth0/android/provider/WebAuthProvider.kt
+++ b/auth0/src/main/java/com/auth0/android/provider/WebAuthProvider.kt
@@ -7,6 +7,7 @@ import android.os.Bundle
import android.util.Log
import androidx.annotation.VisibleForTesting
import com.auth0.android.Auth0
+import com.auth0.android.annotation.ExperimentalAuth0Api
import com.auth0.android.authentication.AuthenticationException
import com.auth0.android.callback.Callback
import com.auth0.android.dpop.DPoP
@@ -299,7 +300,8 @@ public object WebAuthProvider {
}
}
- public class Builder internal constructor(private val account: Auth0) : SenderConstraining {
+ public class Builder internal constructor(private val account: Auth0) :
+ SenderConstraining {
private val values: MutableMap = mutableMapOf()
private val headers: MutableMap = mutableMapOf()
private var pkce: PKCE? = null
@@ -311,6 +313,7 @@ public object WebAuthProvider {
private var ctOptions: CustomTabsOptions = CustomTabsOptions.newBuilder().build()
private var leeway: Int? = null
private var launchAsTwa: Boolean = false
+ private var ephemeralBrowsing: Boolean = false
private var customAuthorizeUrl: String? = null
/**
@@ -525,6 +528,25 @@ public object WebAuthProvider {
return this
}
+ /**
+ * Enable ephemeral browsing for the Custom Tab used in the login flow.
+ * 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.
+ *
+ * **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 the current builder instance
+ */
+ @ExperimentalAuth0Api
+ public fun withEphemeralBrowsing(): Builder {
+ ephemeralBrowsing = true
+ return this
+ }
+
/**
* Specifies a custom Authorize URL to use for this login request, overriding the default
* generated from the Auth0 domain (account.authorizeUrl).
@@ -595,8 +617,15 @@ public object WebAuthProvider {
values[OAuthManager.KEY_ORGANIZATION] = organizationId
values[OAuthManager.KEY_INVITATION] = invitationId
}
+
+ val effectiveCtOptions = if (ephemeralBrowsing) {
+ ctOptions.copyWithEphemeralBrowsing()
+ } else {
+ ctOptions
+ }
+
val manager = OAuthManager(
- account, callback, values, ctOptions, launchAsTwa,
+ account, callback, values, effectiveCtOptions, launchAsTwa,
customAuthorizeUrl, dPoP
)
manager.setHeaders(headers)
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 e6d86183..939dec62 100644
--- a/auth0/src/test/java/com/auth0/android/provider/CustomTabsOptionsTest.java
+++ b/auth0/src/test/java/com/auth0/android/provider/CustomTabsOptionsTest.java
@@ -5,17 +5,20 @@
import android.content.pm.PackageManager;
import android.os.Parcel;
+import androidx.browser.customtabs.CustomTabsClient;
import androidx.browser.customtabs.CustomTabsIntent;
import androidx.core.content.ContextCompat;
+import org.junit.After;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
+import org.mockito.MockedStatic;
+import org.mockito.Mockito;
import org.robolectric.Robolectric;
import org.robolectric.RobolectricTestRunner;
+import org.robolectric.shadows.ShadowLog;
-import java.util.ArrayList;
-import java.util.Arrays;
import java.util.Collections;
import java.util.List;
@@ -25,6 +28,7 @@
import static org.hamcrest.core.IsNull.nullValue;
import static org.junit.Assert.assertEquals;
import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.spy;
import static org.mockito.Mockito.when;
@@ -33,10 +37,20 @@
public class CustomTabsOptionsTest {
private Activity context;
+ private MockedStatic customTabsClientMock;
@Before
public void setUp() {
context = Robolectric.setupActivity(Activity.class);
+ ShadowLog.clear();
+ }
+
+ @After
+ public void tearDown() {
+ if (customTabsClientMock != null) {
+ customTabsClientMock.close();
+ customTabsClientMock = null;
+ }
}
@Test
@@ -224,4 +238,163 @@ public void shouldSetDisabledCustomTabPackages() {
int resolvedColor = ContextCompat.getColor(activity, android.R.color.black);
assertThat(intentWithToolbarExtra.getIntExtra(CustomTabsIntent.EXTRA_TOOLBAR_COLOR, 0), is(resolvedColor));
}
-}
\ No newline at end of file
+
+ @Test
+ public void shouldSetEphemeralBrowsingWhenSupported() {
+ Activity activity = spy(Robolectric.setupActivity(Activity.class));
+ BrowserPickerTest.setupBrowserContext(activity, Collections.singletonList("com.android.chrome"), null, null);
+
+ customTabsClientMock = Mockito.mockStatic(CustomTabsClient.class);
+ customTabsClientMock.when(() ->
+ CustomTabsClient.isEphemeralBrowsingSupported(any(), eq("com.android.chrome"))
+ ).thenReturn(true);
+
+ BrowserPicker browserPicker = BrowserPicker.newBuilder().build();
+ CustomTabsOptions options = CustomTabsOptions.newBuilder()
+ .withBrowserPicker(browserPicker)
+ .withEphemeralBrowsing()
+ .build();
+ assertThat(options, is(notNullValue()));
+
+ Intent intent = options.toIntent(activity, null);
+ assertThat(intent, is(notNullValue()));
+
+ // Verify ephemeral browsing extra is set on the intent
+ assertThat(intent.getBooleanExtra(CustomTabsIntent.EXTRA_ENABLE_EPHEMERAL_BROWSING, false), is(true));
+
+ // Verify isEphemeralBrowsingSupported was called
+ customTabsClientMock.verify(() ->
+ CustomTabsClient.isEphemeralBrowsingSupported(any(), eq("com.android.chrome"))
+ );
+
+ // Verify no warning was logged
+ assertThat(hasLogWithMessage("Ephemeral browsing was requested"), is(false));
+
+ // Verify Parcelable round-trip preserves the ephemeral flag
+ customTabsClientMock.close();
+ customTabsClientMock = Mockito.mockStatic(CustomTabsClient.class);
+ customTabsClientMock.when(() ->
+ CustomTabsClient.isEphemeralBrowsingSupported(any(), eq("com.android.chrome"))
+ ).thenReturn(true);
+
+ Parcel parcel = Parcel.obtain();
+ options.writeToParcel(parcel, 0);
+ parcel.setDataPosition(0);
+ CustomTabsOptions parceledOptions = CustomTabsOptions.CREATOR.createFromParcel(parcel);
+ assertThat(parceledOptions, is(notNullValue()));
+
+ Intent parceledIntent = parceledOptions.toIntent(activity, null);
+ assertThat(parceledIntent, is(notNullValue()));
+
+ // Verify ephemeral browsing extra is set after Parcel round-trip
+ assertThat(parceledIntent.getBooleanExtra(CustomTabsIntent.EXTRA_ENABLE_EPHEMERAL_BROWSING, false), is(true));
+
+ // Verify isEphemeralBrowsingSupported was called again after Parcel round-trip
+ customTabsClientMock.verify(() ->
+ CustomTabsClient.isEphemeralBrowsingSupported(any(), eq("com.android.chrome"))
+ );
+ }
+
+ @Test
+ public void shouldHaveEphemeralBrowsingDisabledByDefault() {
+ customTabsClientMock = Mockito.mockStatic(CustomTabsClient.class);
+
+ CustomTabsOptions options = CustomTabsOptions.newBuilder().build();
+ assertThat(options, is(notNullValue()));
+
+ Intent intent = options.toIntent(context, null);
+ assertThat(intent, is(notNullValue()));
+
+ customTabsClientMock.verifyNoInteractions();
+
+ // Verify ephemeral browsing extra is not set
+ assertThat(intent.getBooleanExtra(CustomTabsIntent.EXTRA_ENABLE_EPHEMERAL_BROWSING, false), is(false));
+
+ assertThat(hasLogWithMessage("Ephemeral browsing was requested"), is(false));
+ }
+
+ @Test
+ public void shouldFallbackWithWarningWhenEphemeralNotSupported() {
+ Activity activity = spy(Robolectric.setupActivity(Activity.class));
+ BrowserPickerTest.setupBrowserContext(activity, Collections.singletonList("com.android.chrome"), null, null);
+
+ customTabsClientMock = Mockito.mockStatic(CustomTabsClient.class);
+ customTabsClientMock.when(() ->
+ CustomTabsClient.isEphemeralBrowsingSupported(any(), eq("com.android.chrome"))
+ ).thenReturn(false);
+
+ BrowserPicker browserPicker = BrowserPicker.newBuilder().build();
+ CustomTabsOptions options = CustomTabsOptions.newBuilder()
+ .withBrowserPicker(browserPicker)
+ .withEphemeralBrowsing()
+ .build();
+
+ Intent intent = options.toIntent(activity, null);
+ assertThat(intent, is(notNullValue()));
+
+ assertThat(hasLogWithMessage("Ephemeral browsing was requested but is not supported"), is(true));
+
+ // Verify ephemeral browsing extra is not set (fallback to regular Custom Tab)
+ assertThat(intent.getBooleanExtra(CustomTabsIntent.EXTRA_ENABLE_EPHEMERAL_BROWSING, false), is(false));
+
+ // Verify the intent still has standard Custom Tab extras (it's a regular Custom Tab)
+ assertThat(intent.hasExtra(CustomTabsIntent.EXTRA_TITLE_VISIBILITY_STATE), is(true));
+ }
+
+ @Test
+ public void shouldFallbackWithWarningWhenPreferredPackageIsNull() {
+ BrowserPicker browserPicker = mock(BrowserPicker.class);
+ when(browserPicker.getBestBrowserPackage(any(PackageManager.class))).thenReturn(null);
+
+ CustomTabsOptions options = CustomTabsOptions.newBuilder()
+ .withBrowserPicker(browserPicker)
+ .withEphemeralBrowsing()
+ .build();
+
+ Intent intent = options.toIntent(context, null);
+ assertThat(intent, is(notNullValue()));
+
+ // Verify ephemeral browsing extra is not set (fallback to regular Custom Tab)
+ assertThat(intent.getBooleanExtra(CustomTabsIntent.EXTRA_ENABLE_EPHEMERAL_BROWSING, false), is(false));
+
+ // Verify the warning was logged with null package info
+ assertThat(hasLogWithMessage("Ephemeral browsing was requested but is not supported"), is(true));
+ assertThat(hasLogWithMessage("(null)"), is(true));
+ }
+
+ @Test
+ public void shouldIgnoreEphemeralBrowsingWhenDisabledCustomTabBrowser() {
+ Activity activity = spy(Robolectric.setupActivity(Activity.class));
+ BrowserPickerTest.setupBrowserContext(activity, Collections.singletonList("com.auth0.browser"), null, null);
+ BrowserPicker browserPicker = BrowserPicker.newBuilder().build();
+
+ CustomTabsOptions options = CustomTabsOptions.newBuilder()
+ .withBrowserPicker(browserPicker)
+ .withDisabledCustomTabsPackages(List.of("com.auth0.browser"))
+ .withEphemeralBrowsing()
+ .build();
+ assertThat(options, is(notNullValue()));
+
+ Intent intent = options.toIntent(activity, null);
+
+ // Should return a plain ACTION_VIEW intent with no extras
+ assertThat(intent, is(notNullValue()));
+ assertThat(intent.getExtras(), is(nullValue()));
+ assertEquals(intent.getAction(), "android.intent.action.VIEW");
+
+ // No warning should be logged since the entire Custom Tab path is skipped
+ assertThat(hasLogWithMessage("Ephemeral browsing was requested"), is(false));
+ }
+
+ /**
+ * Helper to check if a log message containing the given text was emitted.
+ */
+ private boolean hasLogWithMessage(String messageSubstring) {
+ for (ShadowLog.LogItem item : ShadowLog.getLogs()) {
+ if (item.msg != null && item.msg.contains(messageSubstring)) {
+ return true;
+ }
+ }
+ return false;
+ }
+}
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 513d1363..672aeb21 100644
--- a/auth0/src/test/java/com/auth0/android/provider/WebAuthProviderTest.kt
+++ b/auth0/src/test/java/com/auth0/android/provider/WebAuthProviderTest.kt
@@ -3038,6 +3038,29 @@ public class WebAuthProviderTest {
return if (hash.length == 1) "" else hash
}
+ @Test
+ public fun shouldStartLoginWithEphemeralBrowsing() {
+ val options = Mockito.mock(CustomTabsOptions::class.java)
+ val ephemeralOptions = Mockito.mock(CustomTabsOptions::class.java)
+ `when`(options.hasCompatibleBrowser(activity.packageManager)).thenReturn(true)
+ `when`(options.copyWithEphemeralBrowsing()).thenReturn(ephemeralOptions)
+ login(account)
+ .withCustomTabsOptions(options)
+ .withEphemeralBrowsing()
+ .start(activity, callback)
+ verify(options).copyWithEphemeralBrowsing()
+ }
+
+ @Test
+ public fun shouldNotSetEphemeralBrowsingByDefault() {
+ 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()).copyWithEphemeralBrowsing()
+ }
+
private companion object {
private const val KEY_STATE = "state"
private const val KEY_NONCE = "nonce"