From 8e512b11226fc7b97230c87acb03d28e149a8ca2 Mon Sep 17 00:00:00 2001 From: Prince Mathew Date: Wed, 29 Apr 2026 15:03:16 +0530 Subject: [PATCH 01/10] feat: Added support for Auth Tab for web authentication flow --- auth0/build.gradle | 2 +- .../android/provider/AuthTabResultHandler.kt | 26 ++++ .../provider/AuthenticationActivity.kt | 31 ++++- .../provider/CustomTabsController.java | 67 +++++++++- .../android/provider/CustomTabsOptions.java | 58 +++++++- .../auth0/android/provider/WebAuthProvider.kt | 47 ++++++- .../provider/AuthTabResultHandlerTest.kt | 100 ++++++++++++++ .../provider/AuthenticationActivityTest.kt | 55 ++++++++ .../provider/CustomTabsControllerTest.java | 125 +++++++++++++++++- .../provider/CustomTabsOptionsTest.java | 58 ++++++++ .../android/provider/WebAuthProviderTest.kt | 47 +++++++ 11 files changed, 595 insertions(+), 21 deletions(-) create mode 100644 auth0/src/main/java/com/auth0/android/provider/AuthTabResultHandler.kt create mode 100644 auth0/src/test/java/com/auth0/android/provider/AuthTabResultHandlerTest.kt diff --git a/auth0/build.gradle b/auth0/build.gradle index 0aa6b3930..e4f8510cb 100644 --- a/auth0/build.gradle +++ b/auth0/build.gradle @@ -90,7 +90,7 @@ dependencies { implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" implementation 'androidx.core:core-ktx:1.15.0' implementation 'androidx.appcompat:appcompat:1.7.0' - implementation 'androidx.browser:browser:1.9.0' + implementation 'androidx.browser:browser:1.10.0' implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutinesVersion" implementation "com.squareup.okhttp3:okhttp:$okhttpVersion" diff --git a/auth0/src/main/java/com/auth0/android/provider/AuthTabResultHandler.kt b/auth0/src/main/java/com/auth0/android/provider/AuthTabResultHandler.kt new file mode 100644 index 000000000..d76cae121 --- /dev/null +++ b/auth0/src/main/java/com/auth0/android/provider/AuthTabResultHandler.kt @@ -0,0 +1,26 @@ +package com.auth0.android.provider + +import android.app.Activity +import android.net.Uri +import androidx.browser.auth.AuthTabIntent +import com.auth0.android.authentication.AuthenticationException + +internal class AuthTabResultHandler( + private val onSuccess: (Uri?) -> Unit, + private val onFailure: (AuthenticationException) -> Unit, + private val onCancel: () -> Unit +) { + fun handle(resultCode: Int, resultUri: Uri?) { + when (resultCode) { + Activity.RESULT_OK -> onSuccess(resultUri) + AuthTabIntent.RESULT_VERIFICATION_FAILED, + AuthTabIntent.RESULT_VERIFICATION_TIMED_OUT -> onFailure( + AuthenticationException( + "a0.auth_tab_verification_failed", + "Auth Tab redirect URI scheme verification failed." + ) + ) + else -> onCancel() + } + } +} diff --git a/auth0/src/main/java/com/auth0/android/provider/AuthenticationActivity.kt b/auth0/src/main/java/com/auth0/android/provider/AuthenticationActivity.kt index b0f413cc6..740032596 100644 --- a/auth0/src/main/java/com/auth0/android/provider/AuthenticationActivity.kt +++ b/auth0/src/main/java/com/auth0/android/provider/AuthenticationActivity.kt @@ -1,11 +1,13 @@ package com.auth0.android.provider -import android.app.Activity import android.content.Context import android.content.Intent import android.net.Uri import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.result.ActivityResultLauncher import androidx.annotation.VisibleForTesting +import androidx.browser.auth.AuthTabIntent import com.auth0.android.authentication.AuthenticationException import com.auth0.android.authentication.AuthenticationException.Companion.ERROR_KEY_CT_OPTIONS_NULL import com.auth0.android.authentication.AuthenticationException.Companion.ERROR_KEY_URI_NULL @@ -17,14 +19,31 @@ import com.auth0.android.provider.WebAuthProvider.resume import com.auth0.android.request.internal.CommonThreadSwitcher.Companion.getInstance import com.google.androidbrowserhelper.trusted.TwaLauncher -public open class AuthenticationActivity : Activity() { +public open class AuthenticationActivity : ComponentActivity() { private var intentLaunched = false + internal val authTabResultHandler = AuthTabResultHandler( + onSuccess = { uri -> + deliverAuthenticationResult(uri?.let { Intent().setData(it) } ?: Intent()) + finish() + }, + onFailure = { ex -> deliverAuthenticationFailure(ex) }, + onCancel = { + deliverAuthenticationResult(Intent()) + finish() + } + ) + + private val authTabLauncher: ActivityResultLauncher = + AuthTabIntent.registerActivityResultLauncher(this) { result -> + authTabResultHandler.handle(result.resultCode, result.resultUri) + } private var customTabsController: CustomTabsController? = null - override fun onNewIntent(intent: Intent?) { + override fun onNewIntent(intent: Intent) { super.onNewIntent(intent) setIntent(intent) } + @Suppress("DEPRECATION") public override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { val resultData = if (resultCode == RESULT_CANCELED) Intent() else data deliverAuthenticationResult(resultData) @@ -47,6 +66,10 @@ public open class AuthenticationActivity : Activity() { override fun onResume() { super.onResume() + // Auth Tab results are delivered via the Activity Result API callback (onAuthTabResult) + // before onResume, which calls finish(). Without this guard, onResume would treat the + // missing intent data as a cancellation and deliver a second, spurious result. + if (isFinishing) return val authenticationIntent = intent if (!intentLaunched && authenticationIntent.extras == null) { //Activity was launched in an unexpected way @@ -113,7 +136,7 @@ public open class AuthenticationActivity : Activity() { internal open fun createCustomTabsController( context: Context, options: CustomTabsOptions ): CustomTabsController { - return CustomTabsController(context, options, TwaLauncher(context)) + return CustomTabsController(context, options, TwaLauncher(context), authTabLauncher) } @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) diff --git a/auth0/src/main/java/com/auth0/android/provider/CustomTabsController.java b/auth0/src/main/java/com/auth0/android/provider/CustomTabsController.java index d60c57e9f..3bf44ec69 100644 --- a/auth0/src/main/java/com/auth0/android/provider/CustomTabsController.java +++ b/auth0/src/main/java/com/auth0/android/provider/CustomTabsController.java @@ -7,8 +7,12 @@ import android.net.Uri; import android.util.Log; +import androidx.activity.result.ActivityResultLauncher; import androidx.annotation.NonNull; +import androidx.annotation.Nullable; import androidx.annotation.VisibleForTesting; +import androidx.browser.auth.AuthTabIntent; +import androidx.browser.auth.AuthTabSession; import androidx.browser.customtabs.CustomTabsClient; import androidx.browser.customtabs.CustomTabsServiceConnection; import androidx.browser.customtabs.CustomTabsSession; @@ -33,9 +37,12 @@ class CustomTabsController extends CustomTabsServiceConnection { private final WeakReference context; private final AtomicReference session; + private final AtomicReference authTabSession; private final CountDownLatch sessionLatch; private final String preferredPackage; private final TwaLauncher twaLauncher; + @Nullable + private final ActivityResultLauncher authTabLauncher; @NonNull private final CustomTabsOptions customTabsOptions; @@ -44,13 +51,17 @@ class CustomTabsController extends CustomTabsServiceConnection { boolean launchedAsTwa; @VisibleForTesting - CustomTabsController(@NonNull Context context, @NonNull CustomTabsOptions options, @NonNull TwaLauncher twaLauncher) { + CustomTabsController(@NonNull Context context, @NonNull CustomTabsOptions options, + @NonNull TwaLauncher twaLauncher, + @Nullable ActivityResultLauncher authTabLauncher) { this.context = new WeakReference<>(context); this.session = new AtomicReference<>(); + this.authTabSession = new AtomicReference<>(); this.sessionLatch = new CountDownLatch(1); this.customTabsOptions = options; this.preferredPackage = options.getPreferredPackage(context.getPackageManager()); - this.twaLauncher = twaLauncher; + this.twaLauncher = twaLauncher; + this.authTabLauncher = authTabLauncher; } @VisibleForTesting @@ -63,6 +74,9 @@ public void onCustomTabsServiceConnected(@NonNull ComponentName componentName, @ Log.d(TAG, "CustomTabs Service connected"); customTabsClient.warmup(0L); session.set(customTabsClient.newSession(null)); + if (customTabsOptions.isAuthTab()) { + authTabSession.set(customTabsClient.newAuthTabSession(null, Runnable::run)); + } sessionLatch.countDown(); } @@ -70,6 +84,7 @@ public void onCustomTabsServiceConnected(@NonNull ComponentName componentName, @ public void onServiceDisconnected(ComponentName componentName) { Log.d(TAG, "CustomTabs Service disconnected"); session.set(null); + authTabSession.set(null); } /** @@ -128,6 +143,8 @@ public void launchUri(@NonNull final Uri uri, final boolean launchAsTwa, ThreadS null, TwaLauncher.CCT_FALLBACK_STRATEGY ); + } else if (customTabsOptions.isAuthTab()) { + launchAsAuthTab(context, uri); } else { launchAsDefault(context, uri); } @@ -141,6 +158,52 @@ public void launchUri(@NonNull final Uri uri, final boolean launchAsTwa, ThreadS }); } + private void launchAsAuthTab(@NonNull Context context, @NonNull Uri uri) { + if (preferredPackage == null) { + Log.d(TAG, "No compatible browser found for Auth Tab. Falling back to Custom Tab."); + launchAsDefault(context, uri); + return; + } + if (!CustomTabsClient.isAuthTabSupported(context, preferredPackage)) { + Log.d(TAG, "Auth Tab is not supported by " + preferredPackage + ". Falling back to Custom Tab."); + launchAsDefault(context, uri); + return; + } + if (authTabLauncher == null) { + Log.w(TAG, "Auth Tab launcher is not available. Falling back to Custom Tab."); + launchAsDefault(context, uri); + return; + } + String redirectUri = uri.getQueryParameter("redirect_uri"); + if (redirectUri == null) { + Log.w(TAG, "Could not determine redirect URI from authorize URL. Falling back to Custom Tab."); + launchAsDefault(context, uri); + return; + } + + bindService(); + boolean sessionAvailable = false; + try { + sessionAvailable = sessionLatch.await(MAX_WAIT_TIME_SECONDS, TimeUnit.SECONDS); + } catch (InterruptedException ignored) { + } + Log.d(TAG, "Launching URI as Auth Tab. Session available: " + sessionAvailable); + + AuthTabIntent.Builder builder = customTabsOptions.toAuthTabIntentBuilder(context); + AuthTabSession authSession = authTabSession.get(); + if (authSession != null) { + builder.setSession(authSession); + } + AuthTabIntent authTabIntent = builder.build(); + String scheme = Uri.parse(redirectUri).getScheme(); + if (scheme == null) { + Log.w(TAG, "Could not determine scheme from redirect URI: " + redirectUri + ". Falling back to Custom Tab."); + launchAsDefault(context, uri); + return; + } + authTabIntent.launch(authTabLauncher, uri, scheme); + } + private void launchAsDefault(Context context, Uri uri) { bindService(); boolean available = false; 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 02228b6b9..edbd29d9a 100644 --- a/auth0/src/main/java/com/auth0/android/provider/CustomTabsOptions.java +++ b/auth0/src/main/java/com/auth0/android/provider/CustomTabsOptions.java @@ -14,6 +14,8 @@ import androidx.annotation.Dimension; import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import androidx.browser.auth.AuthTabColorSchemeParams; +import androidx.browser.auth.AuthTabIntent; import androidx.browser.customtabs.CustomTabColorSchemeParams; import androidx.browser.customtabs.CustomTabsClient; import androidx.browser.customtabs.CustomTabsIntent; @@ -55,11 +57,14 @@ public class CustomTabsOptions implements Parcelable { // Partial Custom Tabs - Background Interaction private final boolean backgroundInteractionEnabled; + private final boolean authTab; + private CustomTabsOptions(boolean showTitle, @ColorRes int toolbarColor, @NonNull BrowserPicker browserPicker, @Nullable List disabledCustomTabsPackages, int initialHeight, int activityHeightResizeBehavior, int toolbarCornerRadius, int initialWidth, int sideSheetBreakpoint, - boolean backgroundInteractionEnabled, boolean ephemeralBrowsing) { + boolean backgroundInteractionEnabled, boolean ephemeralBrowsing, + boolean authTab) { this.showTitle = showTitle; this.toolbarColor = toolbarColor; this.browserPicker = browserPicker; @@ -71,6 +76,11 @@ private CustomTabsOptions(boolean showTitle, @ColorRes int toolbarColor, @NonNul this.initialWidth = initialWidth; this.sideSheetBreakpoint = sideSheetBreakpoint; this.backgroundInteractionEnabled = backgroundInteractionEnabled; + this.authTab = authTab; + } + + boolean isAuthTab() { + return authTab; } @Nullable @@ -96,7 +106,14 @@ boolean isDisabledCustomTabBrowser(@NonNull String preferredPackage) { CustomTabsOptions copyWithEphemeralBrowsing() { return new CustomTabsOptions(showTitle, toolbarColor, browserPicker, disabledCustomTabsPackages, initialHeight, activityHeightResizeBehavior, toolbarCornerRadius, - initialWidth, sideSheetBreakpoint, backgroundInteractionEnabled, true); + initialWidth, sideSheetBreakpoint, backgroundInteractionEnabled, true, authTab); + } + + @NonNull + CustomTabsOptions copyWithAuthTab() { + return new CustomTabsOptions(showTitle, toolbarColor, browserPicker, + disabledCustomTabsPackages, initialHeight, activityHeightResizeBehavior, toolbarCornerRadius, + initialWidth, sideSheetBreakpoint, backgroundInteractionEnabled, ephemeralBrowsing, true); } /** @@ -164,6 +181,18 @@ Intent toIntent(@NonNull Context context, @Nullable CustomTabsSession session) { return builder.build().intent; } + @SuppressLint("ResourceType") + AuthTabIntent.Builder toAuthTabIntentBuilder(@NonNull Context context) { + AuthTabIntent.Builder builder = new AuthTabIntent.Builder(); + if (toolbarColor > 0) { + final AuthTabColorSchemeParams params = new AuthTabColorSchemeParams.Builder() + .setToolbarColor(ContextCompat.getColor(context, toolbarColor)) + .build(); + builder.setDefaultColorSchemeParams(params); + } + return builder; + } + @SuppressLint("ResourceType") TrustedWebActivityIntentBuilder toTwaIntentBuilder(@NonNull Context context, @NonNull Uri uri) { TrustedWebActivityIntentBuilder builder = new TrustedWebActivityIntentBuilder(uri); @@ -188,6 +217,7 @@ protected CustomTabsOptions(@NonNull Parcel in) { initialWidth = in.readInt(); sideSheetBreakpoint = in.readInt(); backgroundInteractionEnabled = in.readByte() != 0; + authTab = in.readByte() != 0; } @Override @@ -203,6 +233,7 @@ public void writeToParcel(@NonNull Parcel dest, int flags) { dest.writeInt(initialWidth); dest.writeInt(sideSheetBreakpoint); dest.writeByte((byte) (backgroundInteractionEnabled ? 1 : 0)); + dest.writeByte((byte) (authTab ? 1 : 0)); } @Override @@ -235,6 +266,7 @@ public static class Builder { private List disabledCustomTabsPackages; private boolean ephemeralBrowsing; + private boolean authTab; private int initialHeight; private int activityHeightResizeBehavior; @@ -249,6 +281,7 @@ public static class Builder { this.browserPicker = BrowserPicker.newBuilder().build(); this.disabledCustomTabsPackages = null; this.ephemeralBrowsing = false; + this.authTab = false; this.initialHeight = 0; this.activityHeightResizeBehavior = CustomTabsIntent.ACTIVITY_HEIGHT_DEFAULT; this.toolbarCornerRadius = 0; @@ -336,6 +369,24 @@ public Builder withEphemeralBrowsing() { 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. + * By default, Auth Tab is disabled. + * + *

Warning: Auth Tab support in Auth0.Android is still experimental + * and can change in the future.

+ * + * @return this same builder instance. + */ + @ExperimentalAuth0Api + @NonNull + public 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,7 +508,8 @@ 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) { 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 877c9d5b7..5cc629155 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 /** @@ -339,6 +340,22 @@ public object WebAuthProvider { 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. + * + * @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 +402,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 +422,7 @@ public object WebAuthProvider { account, callback, returnToUrl!!, - ctOptions, + effectiveCtOptions, federated, launchAsTwa, customLogoutUrl @@ -456,6 +474,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 /** @@ -689,6 +708,22 @@ 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. + * + * @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 +827,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 000000000..4e23eb960 --- /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 471dbda1f..8e8d5999e 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 7d34e776c..196541963 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; @@ -62,8 +67,11 @@ 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 +90,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 +120,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 +211,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 +233,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 +263,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 +355,107 @@ 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 shouldFallbackToCustomTabWhenRedirectUriHasNoScheme() { + @SuppressWarnings("unchecked") + ActivityResultLauncher mockAuthTabLauncher = mock(ActivityResultLauncher.class); + customTabsClientMock = Mockito.mockStatic(CustomTabsClient.class); + customTabsClientMock.when(() -> CustomTabsClient.isAuthTabSupported(eq(context), any(String.class))).thenReturn(true); + + CustomTabsOptions ctOptions = CustomTabsOptions.newBuilder() + .withAuthTab() + .build(); + + CustomTabsController authTabController = + new CustomTabsController(context, ctOptions, twaLauncher, mockAuthTabLauncher); + + // redirect_uri with no scheme (no "://" part) + Uri authorizeUri = Uri.parse("https://example.auth0.com/authorize?redirect_uri=callback"); + authTabController.launchUri(authorizeUri, false, mockThreadSwitcher, null); + + verify(context, timeout(MAX_TEST_WAIT_TIME_MS)).startActivity(launchIntentCaptor.capture()); + 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 593443464..6d0bd37c6 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,64 @@ 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)); + } + /** * 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 2ff5bdc81..4e9a86b14 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 From 85d15a113be2c4ffc4a5909ea05d396eb8a26169 Mon Sep 17 00:00:00 2001 From: Prince Mathew Date: Wed, 29 Apr 2026 16:22:52 +0530 Subject: [PATCH 02/10] Removed redundant methods from CustomTabsOptions class --- .../android/provider/CustomTabsOptions.java | 63 +++++++------------ .../provider/CustomTabsControllerTest.java | 1 - .../provider/CustomTabsOptionsTest.java | 18 ++++-- 3 files changed, 38 insertions(+), 44 deletions(-) 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 edbd29d9a..6002ff221 100644 --- a/auth0/src/main/java/com/auth0/android/provider/CustomTabsOptions.java +++ b/auth0/src/main/java/com/auth0/android/provider/CustomTabsOptions.java @@ -7,8 +7,8 @@ import android.net.Uri; import android.os.Parcel; import android.os.Parcelable; -import android.util.Log; import android.util.DisplayMetrics; +import android.util.Log; import androidx.annotation.ColorRes; import androidx.annotation.Dimension; @@ -23,7 +23,6 @@ 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; @@ -102,18 +101,32 @@ boolean isDisabledCustomTabBrowser(@NonNull String preferredPackage) { return disabledCustomTabsPackages != null && disabledCustomTabsPackages.contains(preferredPackage); } + @NonNull + Builder toBuilder() { + Builder builder = new Builder(); + builder.showTitle = this.showTitle; + builder.toolbarColor = this.toolbarColor; + builder.browserPicker = this.browserPicker; + builder.disabledCustomTabsPackages = this.disabledCustomTabsPackages; + builder.initialHeight = this.initialHeight; + builder.activityHeightResizeBehavior = this.activityHeightResizeBehavior; + builder.toolbarCornerRadius = this.toolbarCornerRadius; + builder.initialWidth = this.initialWidth; + builder.sideSheetBreakpoint = this.sideSheetBreakpoint; + builder.backgroundInteractionEnabled = this.backgroundInteractionEnabled; + builder.ephemeralBrowsing = this.ephemeralBrowsing; + builder.authTab = this.authTab; + return builder; + } + @NonNull CustomTabsOptions copyWithEphemeralBrowsing() { - return new CustomTabsOptions(showTitle, toolbarColor, browserPicker, - disabledCustomTabsPackages, initialHeight, activityHeightResizeBehavior, toolbarCornerRadius, - initialWidth, sideSheetBreakpoint, backgroundInteractionEnabled, true, authTab); + return toBuilder().withEphemeralBrowsing().build(); } @NonNull CustomTabsOptions copyWithAuthTab() { - return new CustomTabsOptions(showTitle, toolbarColor, browserPicker, - disabledCustomTabsPackages, initialHeight, activityHeightResizeBehavior, toolbarCornerRadius, - initialWidth, sideSheetBreakpoint, backgroundInteractionEnabled, ephemeralBrowsing, true); + return toBuilder().withAuthTab().build(); } /** @@ -348,41 +361,12 @@ 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() { + Builder withEphemeralBrowsing() { this.ephemeralBrowsing = true; 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. - * By default, Auth Tab is disabled. - * - *

Warning: Auth Tab support in Auth0.Android is still experimental - * and can change in the future.

- * - * @return this same builder instance. - */ - @ExperimentalAuth0Api - @NonNull - public Builder withAuthTab() { + Builder withAuthTab() { this.authTab = true; return this; } @@ -512,6 +496,7 @@ public CustomTabsOptions build() { 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/test/java/com/auth0/android/provider/CustomTabsControllerTest.java b/auth0/src/test/java/com/auth0/android/provider/CustomTabsControllerTest.java index 196541963..aa950df59 100644 --- a/auth0/src/test/java/com/auth0/android/provider/CustomTabsControllerTest.java +++ b/auth0/src/test/java/com/auth0/android/provider/CustomTabsControllerTest.java @@ -442,7 +442,6 @@ public void shouldFallbackToCustomTabWhenRedirectUriHasNoScheme() { customTabsClientMock.when(() -> CustomTabsClient.isAuthTabSupported(eq(context), any(String.class))).thenReturn(true); CustomTabsOptions ctOptions = CustomTabsOptions.newBuilder() - .withAuthTab() .build(); CustomTabsController authTabController = 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 6d0bd37c6..aa2808f1d 100644 --- a/auth0/src/test/java/com/auth0/android/provider/CustomTabsOptionsTest.java +++ b/auth0/src/test/java/com/auth0/android/provider/CustomTabsOptionsTest.java @@ -695,8 +695,7 @@ public void shouldSetAuthTab() { @Test public void shouldCopyWithEphemeralBrowsingPreservesAuthTab() { CustomTabsOptions options = CustomTabsOptions.newBuilder() - .withAuthTab() - .build(); + .withAuthTab().build(); assertThat(options.isAuthTab(), is(true)); CustomTabsOptions copied = options.copyWithEphemeralBrowsing(); @@ -715,13 +714,24 @@ public void shouldCopyWithAuthTab() { @Test public void shouldCopyWithAuthTabPreservesEphemeralBrowsing() { CustomTabsOptions options = CustomTabsOptions.newBuilder() - .withEphemeralBrowsing() - .build(); + .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. */ From 061d1d68bc7d293acb2b93215e6055457cb6b830 Mon Sep 17 00:00:00 2001 From: Prince Mathew Date: Wed, 29 Apr 2026 15:03:16 +0530 Subject: [PATCH 03/10] feat: Added support for Auth Tab for web authentication flow --- auth0/build.gradle | 2 +- .../android/provider/AuthTabResultHandler.kt | 26 ++++ .../provider/AuthenticationActivity.kt | 31 ++++- .../provider/CustomTabsController.java | 67 +++++++++- .../android/provider/CustomTabsOptions.java | 58 +++++++- .../auth0/android/provider/WebAuthProvider.kt | 47 ++++++- .../provider/AuthTabResultHandlerTest.kt | 100 ++++++++++++++ .../provider/AuthenticationActivityTest.kt | 55 ++++++++ .../provider/CustomTabsControllerTest.java | 125 +++++++++++++++++- .../provider/CustomTabsOptionsTest.java | 58 ++++++++ .../android/provider/WebAuthProviderTest.kt | 47 +++++++ 11 files changed, 595 insertions(+), 21 deletions(-) create mode 100644 auth0/src/main/java/com/auth0/android/provider/AuthTabResultHandler.kt create mode 100644 auth0/src/test/java/com/auth0/android/provider/AuthTabResultHandlerTest.kt diff --git a/auth0/build.gradle b/auth0/build.gradle index 0aa6b3930..e4f8510cb 100644 --- a/auth0/build.gradle +++ b/auth0/build.gradle @@ -90,7 +90,7 @@ dependencies { implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" implementation 'androidx.core:core-ktx:1.15.0' implementation 'androidx.appcompat:appcompat:1.7.0' - implementation 'androidx.browser:browser:1.9.0' + implementation 'androidx.browser:browser:1.10.0' implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutinesVersion" implementation "com.squareup.okhttp3:okhttp:$okhttpVersion" diff --git a/auth0/src/main/java/com/auth0/android/provider/AuthTabResultHandler.kt b/auth0/src/main/java/com/auth0/android/provider/AuthTabResultHandler.kt new file mode 100644 index 000000000..d76cae121 --- /dev/null +++ b/auth0/src/main/java/com/auth0/android/provider/AuthTabResultHandler.kt @@ -0,0 +1,26 @@ +package com.auth0.android.provider + +import android.app.Activity +import android.net.Uri +import androidx.browser.auth.AuthTabIntent +import com.auth0.android.authentication.AuthenticationException + +internal class AuthTabResultHandler( + private val onSuccess: (Uri?) -> Unit, + private val onFailure: (AuthenticationException) -> Unit, + private val onCancel: () -> Unit +) { + fun handle(resultCode: Int, resultUri: Uri?) { + when (resultCode) { + Activity.RESULT_OK -> onSuccess(resultUri) + AuthTabIntent.RESULT_VERIFICATION_FAILED, + AuthTabIntent.RESULT_VERIFICATION_TIMED_OUT -> onFailure( + AuthenticationException( + "a0.auth_tab_verification_failed", + "Auth Tab redirect URI scheme verification failed." + ) + ) + else -> onCancel() + } + } +} diff --git a/auth0/src/main/java/com/auth0/android/provider/AuthenticationActivity.kt b/auth0/src/main/java/com/auth0/android/provider/AuthenticationActivity.kt index b0f413cc6..740032596 100644 --- a/auth0/src/main/java/com/auth0/android/provider/AuthenticationActivity.kt +++ b/auth0/src/main/java/com/auth0/android/provider/AuthenticationActivity.kt @@ -1,11 +1,13 @@ package com.auth0.android.provider -import android.app.Activity import android.content.Context import android.content.Intent import android.net.Uri import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.result.ActivityResultLauncher import androidx.annotation.VisibleForTesting +import androidx.browser.auth.AuthTabIntent import com.auth0.android.authentication.AuthenticationException import com.auth0.android.authentication.AuthenticationException.Companion.ERROR_KEY_CT_OPTIONS_NULL import com.auth0.android.authentication.AuthenticationException.Companion.ERROR_KEY_URI_NULL @@ -17,14 +19,31 @@ import com.auth0.android.provider.WebAuthProvider.resume import com.auth0.android.request.internal.CommonThreadSwitcher.Companion.getInstance import com.google.androidbrowserhelper.trusted.TwaLauncher -public open class AuthenticationActivity : Activity() { +public open class AuthenticationActivity : ComponentActivity() { private var intentLaunched = false + internal val authTabResultHandler = AuthTabResultHandler( + onSuccess = { uri -> + deliverAuthenticationResult(uri?.let { Intent().setData(it) } ?: Intent()) + finish() + }, + onFailure = { ex -> deliverAuthenticationFailure(ex) }, + onCancel = { + deliverAuthenticationResult(Intent()) + finish() + } + ) + + private val authTabLauncher: ActivityResultLauncher = + AuthTabIntent.registerActivityResultLauncher(this) { result -> + authTabResultHandler.handle(result.resultCode, result.resultUri) + } private var customTabsController: CustomTabsController? = null - override fun onNewIntent(intent: Intent?) { + override fun onNewIntent(intent: Intent) { super.onNewIntent(intent) setIntent(intent) } + @Suppress("DEPRECATION") public override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { val resultData = if (resultCode == RESULT_CANCELED) Intent() else data deliverAuthenticationResult(resultData) @@ -47,6 +66,10 @@ public open class AuthenticationActivity : Activity() { override fun onResume() { super.onResume() + // Auth Tab results are delivered via the Activity Result API callback (onAuthTabResult) + // before onResume, which calls finish(). Without this guard, onResume would treat the + // missing intent data as a cancellation and deliver a second, spurious result. + if (isFinishing) return val authenticationIntent = intent if (!intentLaunched && authenticationIntent.extras == null) { //Activity was launched in an unexpected way @@ -113,7 +136,7 @@ public open class AuthenticationActivity : Activity() { internal open fun createCustomTabsController( context: Context, options: CustomTabsOptions ): CustomTabsController { - return CustomTabsController(context, options, TwaLauncher(context)) + return CustomTabsController(context, options, TwaLauncher(context), authTabLauncher) } @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) diff --git a/auth0/src/main/java/com/auth0/android/provider/CustomTabsController.java b/auth0/src/main/java/com/auth0/android/provider/CustomTabsController.java index d60c57e9f..3bf44ec69 100644 --- a/auth0/src/main/java/com/auth0/android/provider/CustomTabsController.java +++ b/auth0/src/main/java/com/auth0/android/provider/CustomTabsController.java @@ -7,8 +7,12 @@ import android.net.Uri; import android.util.Log; +import androidx.activity.result.ActivityResultLauncher; import androidx.annotation.NonNull; +import androidx.annotation.Nullable; import androidx.annotation.VisibleForTesting; +import androidx.browser.auth.AuthTabIntent; +import androidx.browser.auth.AuthTabSession; import androidx.browser.customtabs.CustomTabsClient; import androidx.browser.customtabs.CustomTabsServiceConnection; import androidx.browser.customtabs.CustomTabsSession; @@ -33,9 +37,12 @@ class CustomTabsController extends CustomTabsServiceConnection { private final WeakReference context; private final AtomicReference session; + private final AtomicReference authTabSession; private final CountDownLatch sessionLatch; private final String preferredPackage; private final TwaLauncher twaLauncher; + @Nullable + private final ActivityResultLauncher authTabLauncher; @NonNull private final CustomTabsOptions customTabsOptions; @@ -44,13 +51,17 @@ class CustomTabsController extends CustomTabsServiceConnection { boolean launchedAsTwa; @VisibleForTesting - CustomTabsController(@NonNull Context context, @NonNull CustomTabsOptions options, @NonNull TwaLauncher twaLauncher) { + CustomTabsController(@NonNull Context context, @NonNull CustomTabsOptions options, + @NonNull TwaLauncher twaLauncher, + @Nullable ActivityResultLauncher authTabLauncher) { this.context = new WeakReference<>(context); this.session = new AtomicReference<>(); + this.authTabSession = new AtomicReference<>(); this.sessionLatch = new CountDownLatch(1); this.customTabsOptions = options; this.preferredPackage = options.getPreferredPackage(context.getPackageManager()); - this.twaLauncher = twaLauncher; + this.twaLauncher = twaLauncher; + this.authTabLauncher = authTabLauncher; } @VisibleForTesting @@ -63,6 +74,9 @@ public void onCustomTabsServiceConnected(@NonNull ComponentName componentName, @ Log.d(TAG, "CustomTabs Service connected"); customTabsClient.warmup(0L); session.set(customTabsClient.newSession(null)); + if (customTabsOptions.isAuthTab()) { + authTabSession.set(customTabsClient.newAuthTabSession(null, Runnable::run)); + } sessionLatch.countDown(); } @@ -70,6 +84,7 @@ public void onCustomTabsServiceConnected(@NonNull ComponentName componentName, @ public void onServiceDisconnected(ComponentName componentName) { Log.d(TAG, "CustomTabs Service disconnected"); session.set(null); + authTabSession.set(null); } /** @@ -128,6 +143,8 @@ public void launchUri(@NonNull final Uri uri, final boolean launchAsTwa, ThreadS null, TwaLauncher.CCT_FALLBACK_STRATEGY ); + } else if (customTabsOptions.isAuthTab()) { + launchAsAuthTab(context, uri); } else { launchAsDefault(context, uri); } @@ -141,6 +158,52 @@ public void launchUri(@NonNull final Uri uri, final boolean launchAsTwa, ThreadS }); } + private void launchAsAuthTab(@NonNull Context context, @NonNull Uri uri) { + if (preferredPackage == null) { + Log.d(TAG, "No compatible browser found for Auth Tab. Falling back to Custom Tab."); + launchAsDefault(context, uri); + return; + } + if (!CustomTabsClient.isAuthTabSupported(context, preferredPackage)) { + Log.d(TAG, "Auth Tab is not supported by " + preferredPackage + ". Falling back to Custom Tab."); + launchAsDefault(context, uri); + return; + } + if (authTabLauncher == null) { + Log.w(TAG, "Auth Tab launcher is not available. Falling back to Custom Tab."); + launchAsDefault(context, uri); + return; + } + String redirectUri = uri.getQueryParameter("redirect_uri"); + if (redirectUri == null) { + Log.w(TAG, "Could not determine redirect URI from authorize URL. Falling back to Custom Tab."); + launchAsDefault(context, uri); + return; + } + + bindService(); + boolean sessionAvailable = false; + try { + sessionAvailable = sessionLatch.await(MAX_WAIT_TIME_SECONDS, TimeUnit.SECONDS); + } catch (InterruptedException ignored) { + } + Log.d(TAG, "Launching URI as Auth Tab. Session available: " + sessionAvailable); + + AuthTabIntent.Builder builder = customTabsOptions.toAuthTabIntentBuilder(context); + AuthTabSession authSession = authTabSession.get(); + if (authSession != null) { + builder.setSession(authSession); + } + AuthTabIntent authTabIntent = builder.build(); + String scheme = Uri.parse(redirectUri).getScheme(); + if (scheme == null) { + Log.w(TAG, "Could not determine scheme from redirect URI: " + redirectUri + ". Falling back to Custom Tab."); + launchAsDefault(context, uri); + return; + } + authTabIntent.launch(authTabLauncher, uri, scheme); + } + private void launchAsDefault(Context context, Uri uri) { bindService(); boolean available = false; 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 02228b6b9..edbd29d9a 100644 --- a/auth0/src/main/java/com/auth0/android/provider/CustomTabsOptions.java +++ b/auth0/src/main/java/com/auth0/android/provider/CustomTabsOptions.java @@ -14,6 +14,8 @@ import androidx.annotation.Dimension; import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import androidx.browser.auth.AuthTabColorSchemeParams; +import androidx.browser.auth.AuthTabIntent; import androidx.browser.customtabs.CustomTabColorSchemeParams; import androidx.browser.customtabs.CustomTabsClient; import androidx.browser.customtabs.CustomTabsIntent; @@ -55,11 +57,14 @@ public class CustomTabsOptions implements Parcelable { // Partial Custom Tabs - Background Interaction private final boolean backgroundInteractionEnabled; + private final boolean authTab; + private CustomTabsOptions(boolean showTitle, @ColorRes int toolbarColor, @NonNull BrowserPicker browserPicker, @Nullable List disabledCustomTabsPackages, int initialHeight, int activityHeightResizeBehavior, int toolbarCornerRadius, int initialWidth, int sideSheetBreakpoint, - boolean backgroundInteractionEnabled, boolean ephemeralBrowsing) { + boolean backgroundInteractionEnabled, boolean ephemeralBrowsing, + boolean authTab) { this.showTitle = showTitle; this.toolbarColor = toolbarColor; this.browserPicker = browserPicker; @@ -71,6 +76,11 @@ private CustomTabsOptions(boolean showTitle, @ColorRes int toolbarColor, @NonNul this.initialWidth = initialWidth; this.sideSheetBreakpoint = sideSheetBreakpoint; this.backgroundInteractionEnabled = backgroundInteractionEnabled; + this.authTab = authTab; + } + + boolean isAuthTab() { + return authTab; } @Nullable @@ -96,7 +106,14 @@ boolean isDisabledCustomTabBrowser(@NonNull String preferredPackage) { CustomTabsOptions copyWithEphemeralBrowsing() { return new CustomTabsOptions(showTitle, toolbarColor, browserPicker, disabledCustomTabsPackages, initialHeight, activityHeightResizeBehavior, toolbarCornerRadius, - initialWidth, sideSheetBreakpoint, backgroundInteractionEnabled, true); + initialWidth, sideSheetBreakpoint, backgroundInteractionEnabled, true, authTab); + } + + @NonNull + CustomTabsOptions copyWithAuthTab() { + return new CustomTabsOptions(showTitle, toolbarColor, browserPicker, + disabledCustomTabsPackages, initialHeight, activityHeightResizeBehavior, toolbarCornerRadius, + initialWidth, sideSheetBreakpoint, backgroundInteractionEnabled, ephemeralBrowsing, true); } /** @@ -164,6 +181,18 @@ Intent toIntent(@NonNull Context context, @Nullable CustomTabsSession session) { return builder.build().intent; } + @SuppressLint("ResourceType") + AuthTabIntent.Builder toAuthTabIntentBuilder(@NonNull Context context) { + AuthTabIntent.Builder builder = new AuthTabIntent.Builder(); + if (toolbarColor > 0) { + final AuthTabColorSchemeParams params = new AuthTabColorSchemeParams.Builder() + .setToolbarColor(ContextCompat.getColor(context, toolbarColor)) + .build(); + builder.setDefaultColorSchemeParams(params); + } + return builder; + } + @SuppressLint("ResourceType") TrustedWebActivityIntentBuilder toTwaIntentBuilder(@NonNull Context context, @NonNull Uri uri) { TrustedWebActivityIntentBuilder builder = new TrustedWebActivityIntentBuilder(uri); @@ -188,6 +217,7 @@ protected CustomTabsOptions(@NonNull Parcel in) { initialWidth = in.readInt(); sideSheetBreakpoint = in.readInt(); backgroundInteractionEnabled = in.readByte() != 0; + authTab = in.readByte() != 0; } @Override @@ -203,6 +233,7 @@ public void writeToParcel(@NonNull Parcel dest, int flags) { dest.writeInt(initialWidth); dest.writeInt(sideSheetBreakpoint); dest.writeByte((byte) (backgroundInteractionEnabled ? 1 : 0)); + dest.writeByte((byte) (authTab ? 1 : 0)); } @Override @@ -235,6 +266,7 @@ public static class Builder { private List disabledCustomTabsPackages; private boolean ephemeralBrowsing; + private boolean authTab; private int initialHeight; private int activityHeightResizeBehavior; @@ -249,6 +281,7 @@ public static class Builder { this.browserPicker = BrowserPicker.newBuilder().build(); this.disabledCustomTabsPackages = null; this.ephemeralBrowsing = false; + this.authTab = false; this.initialHeight = 0; this.activityHeightResizeBehavior = CustomTabsIntent.ACTIVITY_HEIGHT_DEFAULT; this.toolbarCornerRadius = 0; @@ -336,6 +369,24 @@ public Builder withEphemeralBrowsing() { 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. + * By default, Auth Tab is disabled. + * + *

Warning: Auth Tab support in Auth0.Android is still experimental + * and can change in the future.

+ * + * @return this same builder instance. + */ + @ExperimentalAuth0Api + @NonNull + public 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,7 +508,8 @@ 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) { 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 877c9d5b7..5cc629155 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 /** @@ -339,6 +340,22 @@ public object WebAuthProvider { 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. + * + * @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 +402,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 +422,7 @@ public object WebAuthProvider { account, callback, returnToUrl!!, - ctOptions, + effectiveCtOptions, federated, launchAsTwa, customLogoutUrl @@ -456,6 +474,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 /** @@ -689,6 +708,22 @@ 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. + * + * @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 +827,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 000000000..4e23eb960 --- /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 471dbda1f..8e8d5999e 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 7d34e776c..196541963 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; @@ -62,8 +67,11 @@ 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 +90,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 +120,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 +211,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 +233,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 +263,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 +355,107 @@ 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 shouldFallbackToCustomTabWhenRedirectUriHasNoScheme() { + @SuppressWarnings("unchecked") + ActivityResultLauncher mockAuthTabLauncher = mock(ActivityResultLauncher.class); + customTabsClientMock = Mockito.mockStatic(CustomTabsClient.class); + customTabsClientMock.when(() -> CustomTabsClient.isAuthTabSupported(eq(context), any(String.class))).thenReturn(true); + + CustomTabsOptions ctOptions = CustomTabsOptions.newBuilder() + .withAuthTab() + .build(); + + CustomTabsController authTabController = + new CustomTabsController(context, ctOptions, twaLauncher, mockAuthTabLauncher); + + // redirect_uri with no scheme (no "://" part) + Uri authorizeUri = Uri.parse("https://example.auth0.com/authorize?redirect_uri=callback"); + authTabController.launchUri(authorizeUri, false, mockThreadSwitcher, null); + + verify(context, timeout(MAX_TEST_WAIT_TIME_MS)).startActivity(launchIntentCaptor.capture()); + 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 593443464..6d0bd37c6 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,64 @@ 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)); + } + /** * 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 2ff5bdc81..4e9a86b14 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 From 454245e113b544dfe385be9fb09e7cb3ded76548 Mon Sep 17 00:00:00 2001 From: Prince Mathew Date: Wed, 29 Apr 2026 16:22:52 +0530 Subject: [PATCH 04/10] Removed redundant methods from CustomTabsOptions class --- .../android/provider/CustomTabsOptions.java | 63 +++++++------------ .../provider/CustomTabsControllerTest.java | 1 - .../provider/CustomTabsOptionsTest.java | 18 ++++-- 3 files changed, 38 insertions(+), 44 deletions(-) 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 edbd29d9a..6002ff221 100644 --- a/auth0/src/main/java/com/auth0/android/provider/CustomTabsOptions.java +++ b/auth0/src/main/java/com/auth0/android/provider/CustomTabsOptions.java @@ -7,8 +7,8 @@ import android.net.Uri; import android.os.Parcel; import android.os.Parcelable; -import android.util.Log; import android.util.DisplayMetrics; +import android.util.Log; import androidx.annotation.ColorRes; import androidx.annotation.Dimension; @@ -23,7 +23,6 @@ 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; @@ -102,18 +101,32 @@ boolean isDisabledCustomTabBrowser(@NonNull String preferredPackage) { return disabledCustomTabsPackages != null && disabledCustomTabsPackages.contains(preferredPackage); } + @NonNull + Builder toBuilder() { + Builder builder = new Builder(); + builder.showTitle = this.showTitle; + builder.toolbarColor = this.toolbarColor; + builder.browserPicker = this.browserPicker; + builder.disabledCustomTabsPackages = this.disabledCustomTabsPackages; + builder.initialHeight = this.initialHeight; + builder.activityHeightResizeBehavior = this.activityHeightResizeBehavior; + builder.toolbarCornerRadius = this.toolbarCornerRadius; + builder.initialWidth = this.initialWidth; + builder.sideSheetBreakpoint = this.sideSheetBreakpoint; + builder.backgroundInteractionEnabled = this.backgroundInteractionEnabled; + builder.ephemeralBrowsing = this.ephemeralBrowsing; + builder.authTab = this.authTab; + return builder; + } + @NonNull CustomTabsOptions copyWithEphemeralBrowsing() { - return new CustomTabsOptions(showTitle, toolbarColor, browserPicker, - disabledCustomTabsPackages, initialHeight, activityHeightResizeBehavior, toolbarCornerRadius, - initialWidth, sideSheetBreakpoint, backgroundInteractionEnabled, true, authTab); + return toBuilder().withEphemeralBrowsing().build(); } @NonNull CustomTabsOptions copyWithAuthTab() { - return new CustomTabsOptions(showTitle, toolbarColor, browserPicker, - disabledCustomTabsPackages, initialHeight, activityHeightResizeBehavior, toolbarCornerRadius, - initialWidth, sideSheetBreakpoint, backgroundInteractionEnabled, ephemeralBrowsing, true); + return toBuilder().withAuthTab().build(); } /** @@ -348,41 +361,12 @@ 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() { + Builder withEphemeralBrowsing() { this.ephemeralBrowsing = true; 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. - * By default, Auth Tab is disabled. - * - *

Warning: Auth Tab support in Auth0.Android is still experimental - * and can change in the future.

- * - * @return this same builder instance. - */ - @ExperimentalAuth0Api - @NonNull - public Builder withAuthTab() { + Builder withAuthTab() { this.authTab = true; return this; } @@ -512,6 +496,7 @@ public CustomTabsOptions build() { 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/test/java/com/auth0/android/provider/CustomTabsControllerTest.java b/auth0/src/test/java/com/auth0/android/provider/CustomTabsControllerTest.java index 196541963..aa950df59 100644 --- a/auth0/src/test/java/com/auth0/android/provider/CustomTabsControllerTest.java +++ b/auth0/src/test/java/com/auth0/android/provider/CustomTabsControllerTest.java @@ -442,7 +442,6 @@ public void shouldFallbackToCustomTabWhenRedirectUriHasNoScheme() { customTabsClientMock.when(() -> CustomTabsClient.isAuthTabSupported(eq(context), any(String.class))).thenReturn(true); CustomTabsOptions ctOptions = CustomTabsOptions.newBuilder() - .withAuthTab() .build(); CustomTabsController authTabController = 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 6d0bd37c6..aa2808f1d 100644 --- a/auth0/src/test/java/com/auth0/android/provider/CustomTabsOptionsTest.java +++ b/auth0/src/test/java/com/auth0/android/provider/CustomTabsOptionsTest.java @@ -695,8 +695,7 @@ public void shouldSetAuthTab() { @Test public void shouldCopyWithEphemeralBrowsingPreservesAuthTab() { CustomTabsOptions options = CustomTabsOptions.newBuilder() - .withAuthTab() - .build(); + .withAuthTab().build(); assertThat(options.isAuthTab(), is(true)); CustomTabsOptions copied = options.copyWithEphemeralBrowsing(); @@ -715,13 +714,24 @@ public void shouldCopyWithAuthTab() { @Test public void shouldCopyWithAuthTabPreservesEphemeralBrowsing() { CustomTabsOptions options = CustomTabsOptions.newBuilder() - .withEphemeralBrowsing() - .build(); + .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. */ From 3f0f26c7f25dca3b4b739a2122140239bb3c950e Mon Sep 17 00:00:00 2001 From: Prince Mathew Date: Wed, 29 Apr 2026 17:54:25 +0530 Subject: [PATCH 05/10] Added guard for super onActivityResult scenario --- .../java/com/auth0/android/provider/AuthenticationActivity.kt | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/auth0/src/main/java/com/auth0/android/provider/AuthenticationActivity.kt b/auth0/src/main/java/com/auth0/android/provider/AuthenticationActivity.kt index 740032596..a20de764c 100644 --- a/auth0/src/main/java/com/auth0/android/provider/AuthenticationActivity.kt +++ b/auth0/src/main/java/com/auth0/android/provider/AuthenticationActivity.kt @@ -45,6 +45,10 @@ public open class AuthenticationActivity : ComponentActivity() { @Suppress("DEPRECATION") public override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { + super.onActivityResult(requestCode, resultCode, data) + // If the Activity Result API (e.g. auth tab) already handled this result and called + // finish(), skip our legacy delivery to prevent a second delivery. + if (isFinishing) return val resultData = if (resultCode == RESULT_CANCELED) Intent() else data deliverAuthenticationResult(resultData) finish() From a36e71ea1c5a4ce3e1053fc2930eb8e01c1b6523 Mon Sep 17 00:00:00 2001 From: Prince Mathew Date: Wed, 29 Apr 2026 17:57:39 +0530 Subject: [PATCH 06/10] added example for auth tab usage --- EXAMPLES.md | 54 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/EXAMPLES.md b/EXAMPLES.md index 07468c2b9..3a2653439 100644 --- a/EXAMPLES.md +++ b/EXAMPLES.md @@ -13,6 +13,7 @@ - [Specify a Custom Logout URL](#specify-a-custom-logout-url) - [Trusted Web Activity](#trusted-web-activity) - [Ephemeral Browsing [Experimental]](#ephemeral-browsing-experimental) + - [Auth Tab [Experimental]](#auth-tab-experimental) - [DPoP](#dpop) - [Authentication API](#authentication-api) - [Login with database connection](#login-with-database-connection) @@ -329,6 +330,59 @@ WebAuthProvider.login(account) ``` +## Auth Tab [Experimental] + +> **WARNING** +> Auth Tab support in Auth0.Android is still experimental and can change in the future. Please test it thoroughly on all targeted devices and OS variants and let us know your feedback. + +Auth Tab uses [`AuthTabIntent`](https://developer.android.com/reference/androidx/browser/auth/AuthTabIntent) from `androidx.browser` to open the authentication flow in a dedicated browser tab that verifies the redirect URI scheme before delivering the result back to your app. This provides an additional layer of security by ensuring only your app — whose redirect URI scheme is verified at registration time — can receive the authentication callback, preventing other apps from intercepting it. + +Requires `androidx.browser` 1.9.0+ and a browser that supports Auth Tab on the device. On unsupported browsers, the SDK automatically falls back to a regular Custom Tab. + +```kotlin +WebAuthProvider.login(account) + .withAuthTab() + .start(this, callback) +``` + +
+Using async/await + +```kotlin +WebAuthProvider.login(account) + .withAuthTab() + .await(this) +``` +
+ +
+ Using Java + +```java +WebAuthProvider.login(account) + .withAuthTab() + .start(this, callback); +``` +
+ +Auth Tab can also be used for logout: + +```kotlin +WebAuthProvider.logout(account) + .withAuthTab() + .start(this, logoutCallback) +``` + +
+ Using Java + +```java +WebAuthProvider.logout(account) + .withAuthTab() + .start(this, logoutCallback); +``` +
+ ## DPoP [DPoP](https://www.rfc-editor.org/rfc/rfc9449.html) (Demonstrating Proof of Possession) is an application-level mechanism for sender-constraining OAuth 2.0 access and refresh tokens by proving that the app is in possession of a certain private key. You can enable it by calling the `useDPoP(context)` method on the login Builder. From f6959ca86978ba2d170eeba49ac366135ae5f609 Mon Sep 17 00:00:00 2001 From: Prince Mathew Date: Thu, 30 Apr 2026 12:24:07 +0530 Subject: [PATCH 07/10] Updated doc for Auth tab limitations --- EXAMPLES.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/EXAMPLES.md b/EXAMPLES.md index 3a2653439..3f2546110 100644 --- a/EXAMPLES.md +++ b/EXAMPLES.md @@ -383,6 +383,20 @@ WebAuthProvider.logout(account) ``` +### Limitations with `CustomTabsOptions` + +When `withAuthTab()` is combined with `withCustomTabsOptions()`, only a subset of options take effect. Auth Tab uses a separate intent builder (`AuthTabIntent`) and is always presented full-screen. + +| Option | Supported | +|---|---| +| `withToolbarColor()` | ✅ Applied to the Auth Tab toolbar | +| `showTitle()` | ❌ Ignored — Auth Tab has no title-visibility option | +| `withEphemeralBrowsing()` | ❌ Ignored — Auth Tab does not support ephemeral sessions. Use a regular Custom Tab if session isolation is required | +| `withInitialHeight()` / `withInitialWidth()` | ❌ Ignored — Auth Tab is always full-screen | +| `withToolbarCornerRadius()` | ❌ Ignored | +| `withSideSheetBreakpoint()` | ❌ Ignored | +| `withBackgroundInteractionEnabled()` | ❌ Ignored | + ## DPoP [DPoP](https://www.rfc-editor.org/rfc/rfc9449.html) (Demonstrating Proof of Possession) is an application-level mechanism for sender-constraining OAuth 2.0 access and refresh tokens by proving that the app is in possession of a certain private key. You can enable it by calling the `useDPoP(context)` method on the login Builder. From 126c24462870930f76da7c403a65e8c2f4d0975f Mon Sep 17 00:00:00 2001 From: Prince Mathew Date: Thu, 30 Apr 2026 14:07:29 +0530 Subject: [PATCH 08/10] fixed missing authTab in a test --- .../com/auth0/android/provider/CustomTabsControllerTest.java | 1 + 1 file changed, 1 insertion(+) 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 aa950df59..196541963 100644 --- a/auth0/src/test/java/com/auth0/android/provider/CustomTabsControllerTest.java +++ b/auth0/src/test/java/com/auth0/android/provider/CustomTabsControllerTest.java @@ -442,6 +442,7 @@ public void shouldFallbackToCustomTabWhenRedirectUriHasNoScheme() { customTabsClientMock.when(() -> CustomTabsClient.isAuthTabSupported(eq(context), any(String.class))).thenReturn(true); CustomTabsOptions ctOptions = CustomTabsOptions.newBuilder() + .withAuthTab() .build(); CustomTabsController authTabController = From b1747b7e1d4992e1121bb2211dd0444f84894569 Mon Sep 17 00:00:00 2001 From: Prince Mathew Date: Thu, 30 Apr 2026 14:38:40 +0530 Subject: [PATCH 09/10] Addressed review comments --- EXAMPLES.md | 6 ++++++ .../android/provider/CustomTabsController.java | 17 ++++++++--------- .../auth0/android/provider/WebAuthProvider.kt | 14 ++++++++++++++ 3 files changed, 28 insertions(+), 9 deletions(-) diff --git a/EXAMPLES.md b/EXAMPLES.md index 3f2546110..bb3b4e2ea 100644 --- a/EXAMPLES.md +++ b/EXAMPLES.md @@ -294,6 +294,9 @@ WebAuthProvider.login(account) .await(this) ``` +> [!NOTE] +> `withTrustedWebActivity()` and `withAuthTab()` are mutually exclusive. If both are set on the same builder, TWA takes precedence and Auth Tab will not be used. They rely on different underlying launch mechanisms and cannot be combined. For standard OAuth flows against Auth0, prefer [Auth Tab](#auth-tab-experimental) — it requires no server-side setup and works with any domain. + ## Ephemeral Browsing [Experimental] > **WARNING** @@ -339,6 +342,9 @@ Auth Tab uses [`AuthTabIntent`](https://developer.android.com/reference/androidx Requires `androidx.browser` 1.9.0+ and a browser that supports Auth Tab on the device. On unsupported browsers, the SDK automatically falls back to a regular Custom Tab. +> [!NOTE] +> `withAuthTab()` and `withTrustedWebActivity()` are mutually exclusive. If both are set on the same builder, TWA takes precedence and Auth Tab will not be used. They rely on different underlying launch mechanisms and cannot be combined. + ```kotlin WebAuthProvider.login(account) .withAuthTab() diff --git a/auth0/src/main/java/com/auth0/android/provider/CustomTabsController.java b/auth0/src/main/java/com/auth0/android/provider/CustomTabsController.java index 3bf44ec69..5a5bbc04c 100644 --- a/auth0/src/main/java/com/auth0/android/provider/CustomTabsController.java +++ b/auth0/src/main/java/com/auth0/android/provider/CustomTabsController.java @@ -19,7 +19,6 @@ import com.auth0.android.authentication.AuthenticationException; import com.auth0.android.callback.RunnableTask; -import com.auth0.android.request.internal.CommonThreadSwitcher; import com.auth0.android.request.internal.ThreadSwitcher; import com.google.androidbrowserhelper.trusted.TwaLauncher; @@ -52,8 +51,8 @@ class CustomTabsController extends CustomTabsServiceConnection { @VisibleForTesting CustomTabsController(@NonNull Context context, @NonNull CustomTabsOptions options, - @NonNull TwaLauncher twaLauncher, - @Nullable ActivityResultLauncher authTabLauncher) { + @NonNull TwaLauncher twaLauncher, + @Nullable ActivityResultLauncher authTabLauncher) { this.context = new WeakReference<>(context); this.session = new AtomicReference<>(); this.authTabSession = new AtomicReference<>(); @@ -180,6 +179,12 @@ private void launchAsAuthTab(@NonNull Context context, @NonNull Uri uri) { launchAsDefault(context, uri); return; } + String scheme = Uri.parse(redirectUri).getScheme(); + if (scheme == null) { + Log.w(TAG, "Could not determine scheme from redirect URI: " + redirectUri + ". Falling back to Custom Tab."); + launchAsDefault(context, uri); + return; + } bindService(); boolean sessionAvailable = false; @@ -195,12 +200,6 @@ private void launchAsAuthTab(@NonNull Context context, @NonNull Uri uri) { builder.setSession(authSession); } AuthTabIntent authTabIntent = builder.build(); - String scheme = Uri.parse(redirectUri).getScheme(); - if (scheme == null) { - Log.w(TAG, "Could not determine scheme from redirect URI: " + redirectUri + ". Falling back to Custom Tab."); - launchAsDefault(context, uri); - return; - } authTabIntent.launch(authTabLauncher, uri, scheme); } 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 5cc629155..702e22737 100644 --- a/auth0/src/main/java/com/auth0/android/provider/WebAuthProvider.kt +++ b/auth0/src/main/java/com/auth0/android/provider/WebAuthProvider.kt @@ -334,6 +334,9 @@ 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 @@ -348,6 +351,10 @@ public object WebAuthProvider { * **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 @@ -683,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 @@ -716,6 +726,10 @@ public object WebAuthProvider { * **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 From 7211065be9dd9f442b298a5775c2a9cf338341e5 Mon Sep 17 00:00:00 2001 From: Prince Mathew Date: Fri, 1 May 2026 20:46:00 +0530 Subject: [PATCH 10/10] Made the Authtab controller throw error when redirect uri and scheme are not present --- .../provider/CustomTabsController.java | 20 +++-- .../provider/CustomTabsControllerTest.java | 80 +++++++++++++++++-- 2 files changed, 89 insertions(+), 11 deletions(-) diff --git a/auth0/src/main/java/com/auth0/android/provider/CustomTabsController.java b/auth0/src/main/java/com/auth0/android/provider/CustomTabsController.java index 5a5bbc04c..74718df39 100644 --- a/auth0/src/main/java/com/auth0/android/provider/CustomTabsController.java +++ b/auth0/src/main/java/com/auth0/android/provider/CustomTabsController.java @@ -143,7 +143,7 @@ public void launchUri(@NonNull final Uri uri, final boolean launchAsTwa, ThreadS TwaLauncher.CCT_FALLBACK_STRATEGY ); } else if (customTabsOptions.isAuthTab()) { - launchAsAuthTab(context, uri); + launchAsAuthTab(context, uri, threadSwitcher, failureCallback); } else { launchAsDefault(context, uri); } @@ -157,7 +157,7 @@ public void launchUri(@NonNull final Uri uri, final boolean launchAsTwa, ThreadS }); } - private void launchAsAuthTab(@NonNull Context context, @NonNull Uri uri) { + private void launchAsAuthTab(@NonNull Context context, @NonNull Uri uri, @NonNull ThreadSwitcher threadSwitcher, @Nullable RunnableTask failureCallback) { if (preferredPackage == null) { Log.d(TAG, "No compatible browser found for Auth Tab. Falling back to Custom Tab."); launchAsDefault(context, uri); @@ -175,14 +175,22 @@ private void launchAsAuthTab(@NonNull Context context, @NonNull Uri uri) { } String redirectUri = uri.getQueryParameter("redirect_uri"); if (redirectUri == null) { - Log.w(TAG, "Could not determine redirect URI from authorize URL. Falling back to Custom Tab."); - launchAsDefault(context, uri); + Log.e(TAG, "Could not determine redirect URI from authorize URL. This is likely a configuration error."); + if (failureCallback != null) { + AuthenticationException e = new AuthenticationException( + "a0.invalid_authorize_url", "Could not determine redirect URI from authorize URL"); + threadSwitcher.mainThread(() -> failureCallback.apply(e)); + } return; } String scheme = Uri.parse(redirectUri).getScheme(); if (scheme == null) { - Log.w(TAG, "Could not determine scheme from redirect URI: " + redirectUri + ". Falling back to Custom Tab."); - launchAsDefault(context, uri); + Log.e(TAG, "Could not determine scheme from redirect URI: " + redirectUri + ". This is likely a configuration error."); + if (failureCallback != null) { + AuthenticationException e = new AuthenticationException( + "a0.invalid_authorize_url", "Could not determine scheme from redirect URI: " + redirectUri); + threadSwitcher.mainThread(() -> failureCallback.apply(e)); + } return; } 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 196541963..172f41c63 100644 --- a/auth0/src/test/java/com/auth0/android/provider/CustomTabsControllerTest.java +++ b/auth0/src/test/java/com/auth0/android/provider/CustomTabsControllerTest.java @@ -61,6 +61,7 @@ import org.robolectric.RobolectricTestRunner; import java.util.List; +import java.util.concurrent.atomic.AtomicReference; @RunWith(RobolectricTestRunner.class) public class CustomTabsControllerTest { @@ -435,24 +436,93 @@ public void shouldFallbackToCustomTabWhenNoPreferredBrowserForAuthTab() { } @Test - public void shouldFallbackToCustomTabWhenRedirectUriHasNoScheme() { + 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(eq(context), any(String.class))).thenReturn(true); + 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 with no scheme (no "://" part) + // 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"); - authTabController.launchUri(authorizeUri, false, mockThreadSwitcher, null); + AtomicReference capturedException = new AtomicReference<>(); + authTabController.launchUri(authorizeUri, false, mockThreadSwitcher, capturedException::set); - verify(context, timeout(MAX_TEST_WAIT_TIME_MS)).startActivity(launchIntentCaptor.capture()); + 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)); }