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