diff --git a/EXAMPLES.md b/EXAMPLES.md index cc4487ea..65c8d216 100644 --- a/EXAMPLES.md +++ b/EXAMPLES.md @@ -11,6 +11,7 @@ - [Changing the Return To URL scheme](#changing-the-return-to-url-scheme) - [Specify a Custom Logout URL](#specify-a-custom-logout-url) - [Trusted Web Activity](#trusted-web-activity) + - [Ephemeral Browsing [Experimental]](#ephemeral-browsing-experimental) - [DPoP [EA]](#dpop-ea) - [Authentication API](#authentication-api) - [Login with database connection](#login-with-database-connection) @@ -228,6 +229,42 @@ WebAuthProvider.login(account) .await(this) ``` +## Ephemeral Browsing [Experimental] + +> **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. + +Ephemeral browsing launches the Chrome Custom Tab in a fully isolated session — cookies, cache, history, and credentials are deleted when the tab closes. This is equivalent to incognito/private mode for Custom Tabs, useful for privacy-focused authentication flows. + +Requires Chrome 136+ or a compatible browser. On unsupported browsers, the SDK falls back to a regular Custom Tab and logs a warning. + +```kotlin +WebAuthProvider.login(account) + .withEphemeralBrowsing() + .start(this, callback) +``` + +
+Using async/await + +```kotlin +WebAuthProvider.login(account) + .withEphemeralBrowsing() + .await(this) +``` +
+ +
+ Using Java + +```java +WebAuthProvider.login(account) + .withEphemeralBrowsing() + .start(this, callback); +``` +
+ ## DPoP [EA] > [!NOTE] diff --git a/auth0/src/main/java/com/auth0/android/provider/CustomTabsOptions.java b/auth0/src/main/java/com/auth0/android/provider/CustomTabsOptions.java index 765eed20..5df753d4 100644 --- a/auth0/src/main/java/com/auth0/android/provider/CustomTabsOptions.java +++ b/auth0/src/main/java/com/auth0/android/provider/CustomTabsOptions.java @@ -7,16 +7,19 @@ import android.net.Uri; import android.os.Parcel; import android.os.Parcelable; +import android.util.Log; import androidx.annotation.ColorRes; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.browser.customtabs.CustomTabColorSchemeParams; +import androidx.browser.customtabs.CustomTabsClient; import androidx.browser.customtabs.CustomTabsIntent; import androidx.browser.customtabs.CustomTabsSession; import androidx.browser.trusted.TrustedWebActivityIntentBuilder; import androidx.core.content.ContextCompat; +import com.auth0.android.annotation.ExperimentalAuth0Api; import com.auth0.android.authentication.AuthenticationException; import java.util.List; @@ -26,6 +29,8 @@ */ public class CustomTabsOptions implements Parcelable { + private static final String TAG = "CustomTabsOptions"; + private final boolean showTitle; @ColorRes private final int toolbarColor; @@ -34,11 +39,14 @@ public class CustomTabsOptions implements Parcelable { @Nullable private final List disabledCustomTabsPackages; - private CustomTabsOptions(boolean showTitle, @ColorRes int toolbarColor, @NonNull BrowserPicker browserPicker, @Nullable List disabledCustomTabsPackages) { + private final boolean ephemeralBrowsing; + + private CustomTabsOptions(boolean showTitle, @ColorRes int toolbarColor, @NonNull BrowserPicker browserPicker, @Nullable List disabledCustomTabsPackages, boolean ephemeralBrowsing) { this.showTitle = showTitle; this.toolbarColor = toolbarColor; this.browserPicker = browserPicker; this.disabledCustomTabsPackages = disabledCustomTabsPackages; + this.ephemeralBrowsing = ephemeralBrowsing; } @Nullable @@ -60,6 +68,12 @@ boolean isDisabledCustomTabBrowser(@NonNull String preferredPackage) { return disabledCustomTabsPackages != null && disabledCustomTabsPackages.contains(preferredPackage); } + @NonNull + CustomTabsOptions copyWithEphemeralBrowsing() { + return new CustomTabsOptions(showTitle, toolbarColor, browserPicker, + disabledCustomTabsPackages, true); + } + /** * Create a new CustomTabsOptions.Builder instance. * @@ -82,6 +96,18 @@ Intent toIntent(@NonNull Context context, @Nullable CustomTabsSession session) { final CustomTabsIntent.Builder builder = new CustomTabsIntent.Builder(session) .setShowTitle(showTitle) .setShareState(CustomTabsIntent.SHARE_STATE_OFF); + + if (ephemeralBrowsing) { + if (preferredPackage != null + && CustomTabsClient.isEphemeralBrowsingSupported(context, preferredPackage)) { + builder.setEphemeralBrowsingEnabled(true); + } else { + Log.w(TAG, "Ephemeral browsing was requested but is not supported by the " + + "current browser (" + preferredPackage + "). " + + "Falling back to a regular Custom Tab."); + } + } + if (toolbarColor > 0) { //Resource exists final CustomTabColorSchemeParams.Builder colorBuilder = new CustomTabColorSchemeParams.Builder() @@ -108,6 +134,7 @@ protected CustomTabsOptions(@NonNull Parcel in) { toolbarColor = in.readInt(); browserPicker = in.readParcelable(BrowserPicker.class.getClassLoader()); disabledCustomTabsPackages = in.createStringArrayList(); + ephemeralBrowsing = in.readByte() != 0; } @Override @@ -116,6 +143,7 @@ public void writeToParcel(@NonNull Parcel dest, int flags) { dest.writeInt(toolbarColor); dest.writeParcelable(browserPicker, flags); dest.writeStringList(disabledCustomTabsPackages); + dest.writeByte((byte) (ephemeralBrowsing ? 1 : 0)); } @Override @@ -147,11 +175,14 @@ public static class Builder { @Nullable private List disabledCustomTabsPackages; + private boolean ephemeralBrowsing; + Builder() { this.showTitle = false; this.toolbarColor = 0; this.browserPicker = BrowserPicker.newBuilder().build(); this.disabledCustomTabsPackages = null; + this.ephemeralBrowsing = false; } /** @@ -212,6 +243,27 @@ public Builder withDisabledCustomTabsPackages(List 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"