From b6cacb38cd0c728e152bacde352a1e9728da7820 Mon Sep 17 00:00:00 2001 From: utkrishtS Date: Tue, 24 Mar 2026 19:29:58 +0530 Subject: [PATCH 01/10] fix: handle configuration changes during WebAuth flow to prevent memory leak --- EXAMPLES.md | 51 +++++++- V4_MIGRATION_GUIDE.md | 40 +++++++ .../auth0/android/provider/LogoutManager.kt | 30 ++++- .../auth0/android/provider/OAuthManager.kt | 39 +++++-- .../auth0/android/provider/WebAuthProvider.kt | 60 ++++++++++ .../android/provider/WebAuthProviderTest.kt | 109 ++++++++++++++++++ 6 files changed, 316 insertions(+), 13 deletions(-) diff --git a/EXAMPLES.md b/EXAMPLES.md index 2b5690269..3c05cdf2e 100644 --- a/EXAMPLES.md +++ b/EXAMPLES.md @@ -329,9 +329,58 @@ WebAuthProvider.logout(account) }) ``` -> [!NOTE] +> [!NOTE] > DPoP is supported only on Android version 6.0 (API level 23) and above. Trying to use DPoP in any older versions will result in an exception. +## Handling Configuration Changes During Authentication + +When the Activity is destroyed during authentication due to a configuration change (e.g. device rotation, locale change, dark mode toggle), the SDK caches the authentication result internally. Use `consumePendingLoginResult()` or `consumePendingLogoutResult()` in your `onResume()` to recover it. + +```kotlin +class LoginActivity : AppCompatActivity() { + + private val loginCallback = object : Callback { + override fun onSuccess(result: Credentials) { + // Handle successful login + } + override fun onFailure(error: AuthenticationException) { + // Handle error + } + } + + private val logoutCallback = object : Callback { + override fun onSuccess(result: Void?) { + // Handle successful logout + } + override fun onFailure(error: AuthenticationException) { + // Handle error + } + } + + override fun onResume() { + super.onResume() + // Recover any result that arrived while the Activity was being recreated + WebAuthProvider.consumePendingLoginResult(loginCallback) + WebAuthProvider.consumePendingLogoutResult(logoutCallback) + } + + fun onLoginClick() { + WebAuthProvider.login(account) + .withScheme("demo") + .start(this, loginCallback) + } + + fun onLogoutClick() { + WebAuthProvider.logout(account) + .withScheme("demo") + .start(this, logoutCallback) + } +} +``` + +> [!NOTE] +> If you use the `suspend fun await()` API from a ViewModel coroutine scope, the Activity is never captured in the callback chain, so you do not need `consumePending*` calls. + ## Authentication API The client provides methods to authenticate the user against the Auth0 server. diff --git a/V4_MIGRATION_GUIDE.md b/V4_MIGRATION_GUIDE.md index ea4fe01eb..9839d6681 100644 --- a/V4_MIGRATION_GUIDE.md +++ b/V4_MIGRATION_GUIDE.md @@ -24,6 +24,8 @@ v4 of the Auth0 Android SDK includes significant build toolchain updates, update - [**Dependency Changes**](#dependency-changes) + [Gson 2.8.9 → 2.11.0](#️-gson-289--2110-transitive-dependency) + [DefaultClient.Builder](#defaultclientbuilder) +- [**New APIs**](#new-apis) + + [Handling Configuration Changes During Authentication](#handling-configuration-changes-during-authentication) --- @@ -283,6 +285,44 @@ The legacy constructor is deprecated but **not removed** — existing code will and run. Your IDE will show a deprecation warning with a suggested `ReplaceWith` quick-fix to migrate to the Builder. +## New APIs + +### Handling Configuration Changes During Authentication + +v4 fixes a memory leak and lost callback issue when the Activity is destroyed during authentication +(e.g. device rotation, locale change, dark mode toggle). The SDK now uses `WeakReference` for +callbacks, so destroyed Activities are properly garbage collected. + +If the authentication result arrives while the Activity is being recreated, it is cached internally. +Use `consumePendingLoginResult()` or `consumePendingLogoutResult()` in your `onResume()` to recover it: + +```kotlin +class LoginActivity : AppCompatActivity() { + private val callback = object : Callback { + override fun onSuccess(result: Credentials) { /* handle credentials */ } + override fun onFailure(error: AuthenticationException) { /* handle error */ } + } + + override fun onResume() { + super.onResume() + // Recover result that arrived during configuration change + WebAuthProvider.consumePendingLoginResult(callback) + } + + fun onLoginClick() { + WebAuthProvider.login(account) + .withScheme("myapp") + .start(this, callback) + } +} +``` + +For logout flows, use `WebAuthProvider.consumePendingLogoutResult(callback)` in the same way. + +> **Note:** If you use the `suspend fun await()` API from a ViewModel coroutine scope, the +> Activity is never captured in the callback chain, so you do not need `consumePending*` calls. +> See the sample app for a ViewModel-based example. + ## Getting Help If you encounter issues during migration: diff --git a/auth0/src/main/java/com/auth0/android/provider/LogoutManager.kt b/auth0/src/main/java/com/auth0/android/provider/LogoutManager.kt index 824c73713..2b515c3e9 100644 --- a/auth0/src/main/java/com/auth0/android/provider/LogoutManager.kt +++ b/auth0/src/main/java/com/auth0/android/provider/LogoutManager.kt @@ -6,17 +6,39 @@ import android.util.Log import com.auth0.android.Auth0 import com.auth0.android.authentication.AuthenticationException import com.auth0.android.callback.Callback +import java.lang.ref.WeakReference import java.util.* internal class LogoutManager( private val account: Auth0, - private val callback: Callback, + callback: Callback, returnToUrl: String, ctOptions: CustomTabsOptions, federated: Boolean = false, private val launchAsTwa: Boolean = false, private val customLogoutUrl: String? = null ) : ResumableManager() { + private val callbackRef = WeakReference(callback) + + private fun deliverSuccess() { + val cb = callbackRef.get() + if (cb != null) { + cb.onSuccess(null) + } else { + WebAuthProvider.pendingLogoutResult = + WebAuthProvider.PendingResult.Success(null) + } + } + + private fun deliverFailure(error: AuthenticationException) { + val cb = callbackRef.get() + if (cb != null) { + cb.onFailure(error) + } else { + WebAuthProvider.pendingLogoutResult = + WebAuthProvider.PendingResult.Failure(error) + } + } private val parameters: MutableMap private val ctOptions: CustomTabsOptions fun startLogout(context: Context) { @@ -31,15 +53,15 @@ internal class LogoutManager( AuthenticationException.ERROR_VALUE_AUTHENTICATION_CANCELED, "The user closed the browser app so the logout was cancelled." ) - callback.onFailure(exception) + deliverFailure(exception) } else { - callback.onSuccess(null) + deliverSuccess() } return true } override fun failure(exception: AuthenticationException) { - callback.onFailure(exception) + deliverFailure(exception) } private fun buildLogoutUri(): Uri { diff --git a/auth0/src/main/java/com/auth0/android/provider/OAuthManager.kt b/auth0/src/main/java/com/auth0/android/provider/OAuthManager.kt index b09366e61..12ea9a547 100644 --- a/auth0/src/main/java/com/auth0/android/provider/OAuthManager.kt +++ b/auth0/src/main/java/com/auth0/android/provider/OAuthManager.kt @@ -16,12 +16,13 @@ import com.auth0.android.dpop.DPoPException import com.auth0.android.request.internal.Jwt import com.auth0.android.request.internal.OidcUtils import com.auth0.android.result.Credentials +import java.lang.ref.WeakReference import java.security.SecureRandom import java.util.* internal class OAuthManager( private val account: Auth0, - private val callback: Callback, + callback: Callback, parameters: Map, ctOptions: CustomTabsOptions, private val launchAsTwa: Boolean = false, @@ -29,6 +30,28 @@ internal class OAuthManager( @get:VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) internal val dPoP: DPoP? = null ) : ResumableManager() { + private val callbackRef = WeakReference(callback) + + private fun deliverSuccess(credentials: Credentials) { + val cb = callbackRef.get() + if (cb != null) { + cb.onSuccess(credentials) + } else { + WebAuthProvider.pendingLoginResult = + WebAuthProvider.PendingResult.Success(credentials) + } + } + + private fun deliverFailure(error: AuthenticationException) { + val cb = callbackRef.get() + if (cb != null) { + cb.onFailure(error) + } else { + WebAuthProvider.pendingLoginResult = + WebAuthProvider.PendingResult.Failure(error) + } + } + private val parameters: MutableMap private val headers: MutableMap private val ctOptions: CustomTabsOptions @@ -68,7 +91,7 @@ internal class OAuthManager( try { addDPoPJWKParameters(parameters) } catch (ex: DPoPException) { - callback.onFailure( + deliverFailure( AuthenticationException( ex.message ?: "Error generating the JWK", ex @@ -97,7 +120,7 @@ internal class OAuthManager( AuthenticationException.ERROR_VALUE_AUTHENTICATION_CANCELED, "The user closed the browser app and the authentication was canceled." ) - callback.onFailure(exception) + deliverFailure(exception) return true } val values = CallbackHelper.getValuesFromUri(result.intentData) @@ -110,7 +133,7 @@ internal class OAuthManager( assertNoError(values[KEY_ERROR], values[KEY_ERROR_DESCRIPTION]) assertValidState(parameters[KEY_STATE]!!, values[KEY_STATE]) } catch (e: AuthenticationException) { - callback.onFailure(e) + deliverFailure(e) return true } @@ -123,14 +146,14 @@ internal class OAuthManager( credentials.idToken, object : Callback { override fun onSuccess(result: Void?) { - callback.onSuccess(credentials) + deliverSuccess(credentials) } override fun onFailure(error: Auth0Exception) { val wrappedError = AuthenticationException( ERROR_VALUE_ID_TOKEN_VALIDATION_FAILED, error ) - callback.onFailure(wrappedError) + deliverFailure(wrappedError) } }) } @@ -142,14 +165,14 @@ internal class OAuthManager( "Unable to complete authentication with PKCE. PKCE support can be enabled by setting Application Type to 'Native' and Token Endpoint Authentication Method to 'None' for this app at 'https://manage.auth0.com/#/applications/" + apiClient.clientId + "/settings'." ) } - callback.onFailure(error) + deliverFailure(error) } }) return true } public override fun failure(exception: AuthenticationException) { - callback.onFailure(exception) + deliverFailure(exception) } private fun assertValidIdToken( 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 70cf647f3..1b138362b 100644 --- a/auth0/src/main/java/com/auth0/android/provider/WebAuthProvider.kt +++ b/auth0/src/main/java/com/auth0/android/provider/WebAuthProvider.kt @@ -39,6 +39,66 @@ public object WebAuthProvider { internal var managerInstance: ResumableManager? = null private set + /** + * Represents a pending authentication or logout result that arrived while + * the original callback was no longer reachable (e.g. Activity destroyed + * during a configuration change). + */ + internal sealed class PendingResult { + data class Success(val result: S) : PendingResult() + data class Failure(val error: E) : PendingResult() + } + + @Volatile + @JvmStatic + @get:VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) + internal var pendingLoginResult: PendingResult? = null + + @Volatile + @JvmStatic + @get:VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) + internal var pendingLogoutResult: PendingResult? = null + + /** + * Check for and consume a pending login result that arrived during a configuration change. + * Call this in your Activity's `onResume()` to recover results that were delivered while the + * Activity was being recreated (e.g. due to screen rotation). + * + * @param callback the callback to deliver the pending result to + * @return true if a pending result was found and delivered, false otherwise + */ + @JvmStatic + public fun consumePendingLoginResult(callback: Callback): Boolean { + val result = pendingLoginResult ?: return false + pendingLoginResult = null + when (result) { + is PendingResult.Success -> callback.onSuccess(result.result) + is PendingResult.Failure -> callback.onFailure(result.error) + } + resetManagerInstance() + return true + } + + /** + * Check for and consume a pending logout result that arrived during a configuration change. + * Call this in your Activity's `onResume()` to recover results that were delivered while the + * Activity was being recreated (e.g. due to screen rotation). + * + * @param callback the callback to deliver the pending result to + * @return true if a pending result was found and delivered, false otherwise + */ + @JvmStatic + public fun consumePendingLogoutResult(callback: Callback): Boolean { + val result = pendingLogoutResult ?: return false + pendingLogoutResult = null + when (result) { + is PendingResult.Success -> callback.onSuccess(result.result) + is PendingResult.Failure -> callback.onFailure(result.error) + } + resetManagerInstance() + return true + } + @JvmStatic public fun addCallback(callback: Callback) { callbacks += callback 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 e8bebf333..25e5747a1 100644 --- a/auth0/src/test/java/com/auth0/android/provider/WebAuthProviderTest.kt +++ b/auth0/src/test/java/com/auth0/android/provider/WebAuthProviderTest.kt @@ -113,6 +113,9 @@ public class WebAuthProviderTest { ) `when`(mockKeyStore.hasKeyPair()).thenReturn(false) + + WebAuthProvider.pendingLoginResult = null + WebAuthProvider.pendingLogoutResult = null } @@ -3064,6 +3067,112 @@ public class WebAuthProviderTest { verify(options, Mockito.never()).copyWithEphemeralBrowsing() } + @Test + public fun shouldConsumePendingLoginSuccessResult() { + val credentials = Mockito.mock(Credentials::class.java) + WebAuthProvider.pendingLoginResult = WebAuthProvider.PendingResult.Success(credentials) + + val consumed = WebAuthProvider.consumePendingLoginResult(callback) + Assert.assertTrue(consumed) + verify(callback).onSuccess(credentials) + Assert.assertNull(WebAuthProvider.pendingLoginResult) + } + + @Test + public fun shouldConsumePendingLoginFailureResult() { + val error = AuthenticationException("test_error", "Test error description") + WebAuthProvider.pendingLoginResult = WebAuthProvider.PendingResult.Failure(error) + + val consumed = WebAuthProvider.consumePendingLoginResult(callback) + Assert.assertTrue(consumed) + verify(callback).onFailure(error) + Assert.assertNull(WebAuthProvider.pendingLoginResult) + } + + @Test + public fun shouldReturnFalseWhenNoPendingLoginResult() { + WebAuthProvider.pendingLoginResult = null + + val consumed = WebAuthProvider.consumePendingLoginResult(callback) + Assert.assertFalse(consumed) + verify(callback, Mockito.never()).onSuccess(any()) + verify(callback, Mockito.never()).onFailure(any()) + } + + @Test + public fun shouldNotConsumeLoginResultTwice() { + val credentials = Mockito.mock(Credentials::class.java) + WebAuthProvider.pendingLoginResult = WebAuthProvider.PendingResult.Success(credentials) + + Assert.assertTrue(WebAuthProvider.consumePendingLoginResult(callback)) + Assert.assertFalse(WebAuthProvider.consumePendingLoginResult(callback)) + verify(callback, times(1)).onSuccess(credentials) + } + + @Test + public fun shouldConsumePendingLogoutSuccessResult() { + WebAuthProvider.pendingLogoutResult = WebAuthProvider.PendingResult.Success(null) + + val consumed = WebAuthProvider.consumePendingLogoutResult(voidCallback) + Assert.assertTrue(consumed) + verify(voidCallback).onSuccess(null) + Assert.assertNull(WebAuthProvider.pendingLogoutResult) + } + + @Test + public fun shouldConsumePendingLogoutFailureResult() { + val error = AuthenticationException("test_error", "Test error description") + WebAuthProvider.pendingLogoutResult = WebAuthProvider.PendingResult.Failure(error) + + val consumed = WebAuthProvider.consumePendingLogoutResult(voidCallback) + Assert.assertTrue(consumed) + verify(voidCallback).onFailure(error) + Assert.assertNull(WebAuthProvider.pendingLogoutResult) + } + + @Test + public fun shouldReturnFalseWhenNoPendingLogoutResult() { + WebAuthProvider.pendingLogoutResult = null + + val consumed = WebAuthProvider.consumePendingLogoutResult(voidCallback) + Assert.assertFalse(consumed) + verify(voidCallback, Mockito.never()).onSuccess(any()) + verify(voidCallback, Mockito.never()).onFailure(any()) + } + + @Test + public fun shouldCacheLoginResultWhenCallbackIsGarbageCollected() { + WebAuthProvider.pendingLoginResult = null + val credentials = Mockito.mock(Credentials::class.java) + + + var weakCallback: Callback? = + object : Callback { + override fun onSuccess(result: Credentials) {} + override fun onFailure(error: AuthenticationException) {} + } + val manager = OAuthManager( + account, + weakCallback!!, + mapOf("response_type" to "code", "state" to "teststate", "nonce" to "testnonce"), + CustomTabsOptions.newBuilder().build() + ) + @Suppress("UNUSED_VALUE") + weakCallback = null + System.gc() + Thread.sleep(100) + + val exception = AuthenticationException( + AuthenticationException.ERROR_VALUE_AUTHENTICATION_CANCELED, + "The user closed the browser app and the authentication was canceled." + ) + manager.failure(exception) + + val pending = WebAuthProvider.pendingLoginResult + Assert.assertNotNull(pending) + Assert.assertTrue(pending is WebAuthProvider.PendingResult.Failure) + } + private companion object { private const val KEY_STATE = "state" private const val KEY_NONCE = "nonce" From 8058fe8345eb143dcdfca15afdc493276cf99413 Mon Sep 17 00:00:00 2001 From: utkrishtS Date: Tue, 31 Mar 2026 17:08:44 +0530 Subject: [PATCH 02/10] fix: handle configuration changes during WebAuth flow to prevent memory leak and lost results - Add LifecycleAwareCallback to null the user callback on onDestroy, eliminating the Activity memory leak during rotation - Cache results arriving after destroy in AtomicReference (pendingLoginResult / pendingLogoutResult) for recovery via consumePendingLoginResult / consumePendingLogoutResult in onResume - Revert OAuthManager and LogoutManager to hold a strong callback reference (leak prevention is now LifecycleAwareCallback's responsibility) - Add 19 unit tests covering config-change caching, double-consume protection, observer lifecycle, and stale-result clearing --- .../provider/LifecycleAwareCallback.kt | 53 ++++ .../auth0/android/provider/LogoutManager.kt | 30 +- .../auth0/android/provider/OAuthManager.kt | 38 +-- .../auth0/android/provider/WebAuthProvider.kt | 72 ++++- .../android/provider/WebAuthProviderTest.kt | 271 +++++++++++++++--- 5 files changed, 361 insertions(+), 103 deletions(-) create mode 100644 auth0/src/main/java/com/auth0/android/provider/LifecycleAwareCallback.kt diff --git a/auth0/src/main/java/com/auth0/android/provider/LifecycleAwareCallback.kt b/auth0/src/main/java/com/auth0/android/provider/LifecycleAwareCallback.kt new file mode 100644 index 000000000..d9f37de94 --- /dev/null +++ b/auth0/src/main/java/com/auth0/android/provider/LifecycleAwareCallback.kt @@ -0,0 +1,53 @@ +package com.auth0.android.provider + +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.LifecycleOwner +import com.auth0.android.authentication.AuthenticationException +import com.auth0.android.callback.Callback + +/** + * Wraps a user-provided callback and observes the Activity/Fragment lifecycle. + * When the host is destroyed (e.g. config change), [inner] is set to null so + * the destroyed Activity is no longer referenced by the SDK. + * + * If a result arrives after [inner] has been cleared, the [onDetached] lambda + * is invoked to cache the result for later recovery via consumePending*Result(). + * + * @param S the success type (Credentials for login, Void? for logout) + * @param inner the user's original callback + * @param lifecycleOwner the Activity or Fragment whose lifecycle to observe + * @param onDetached called when a result arrives but the callback is already detached + */ +internal class LifecycleAwareCallback( + @Volatile private var inner: Callback?, + lifecycleOwner: LifecycleOwner, + private val onDetached: (success: S?, error: AuthenticationException?) -> Unit, +) : Callback, DefaultLifecycleObserver { + + init { + lifecycleOwner.lifecycle.addObserver(this) + } + + override fun onSuccess(result: S) { + val cb = inner + if (cb != null) { + cb.onSuccess(result) + } else { + onDetached(result, null) + } + } + + override fun onFailure(error: AuthenticationException) { + val cb = inner + if (cb != null) { + cb.onFailure(error) + } else { + onDetached(null, error) + } + } + + override fun onDestroy(owner: LifecycleOwner) { + inner = null + owner.lifecycle.removeObserver(this) + } +} diff --git a/auth0/src/main/java/com/auth0/android/provider/LogoutManager.kt b/auth0/src/main/java/com/auth0/android/provider/LogoutManager.kt index 2b515c3e9..824c73713 100644 --- a/auth0/src/main/java/com/auth0/android/provider/LogoutManager.kt +++ b/auth0/src/main/java/com/auth0/android/provider/LogoutManager.kt @@ -6,39 +6,17 @@ import android.util.Log import com.auth0.android.Auth0 import com.auth0.android.authentication.AuthenticationException import com.auth0.android.callback.Callback -import java.lang.ref.WeakReference import java.util.* internal class LogoutManager( private val account: Auth0, - callback: Callback, + private val callback: Callback, returnToUrl: String, ctOptions: CustomTabsOptions, federated: Boolean = false, private val launchAsTwa: Boolean = false, private val customLogoutUrl: String? = null ) : ResumableManager() { - private val callbackRef = WeakReference(callback) - - private fun deliverSuccess() { - val cb = callbackRef.get() - if (cb != null) { - cb.onSuccess(null) - } else { - WebAuthProvider.pendingLogoutResult = - WebAuthProvider.PendingResult.Success(null) - } - } - - private fun deliverFailure(error: AuthenticationException) { - val cb = callbackRef.get() - if (cb != null) { - cb.onFailure(error) - } else { - WebAuthProvider.pendingLogoutResult = - WebAuthProvider.PendingResult.Failure(error) - } - } private val parameters: MutableMap private val ctOptions: CustomTabsOptions fun startLogout(context: Context) { @@ -53,15 +31,15 @@ internal class LogoutManager( AuthenticationException.ERROR_VALUE_AUTHENTICATION_CANCELED, "The user closed the browser app so the logout was cancelled." ) - deliverFailure(exception) + callback.onFailure(exception) } else { - deliverSuccess() + callback.onSuccess(null) } return true } override fun failure(exception: AuthenticationException) { - deliverFailure(exception) + callback.onFailure(exception) } private fun buildLogoutUri(): Uri { diff --git a/auth0/src/main/java/com/auth0/android/provider/OAuthManager.kt b/auth0/src/main/java/com/auth0/android/provider/OAuthManager.kt index 12ea9a547..718d3a788 100644 --- a/auth0/src/main/java/com/auth0/android/provider/OAuthManager.kt +++ b/auth0/src/main/java/com/auth0/android/provider/OAuthManager.kt @@ -16,13 +16,12 @@ import com.auth0.android.dpop.DPoPException import com.auth0.android.request.internal.Jwt import com.auth0.android.request.internal.OidcUtils import com.auth0.android.result.Credentials -import java.lang.ref.WeakReference import java.security.SecureRandom import java.util.* internal class OAuthManager( private val account: Auth0, - callback: Callback, + private val callback: Callback, parameters: Map, ctOptions: CustomTabsOptions, private val launchAsTwa: Boolean = false, @@ -30,27 +29,6 @@ internal class OAuthManager( @get:VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) internal val dPoP: DPoP? = null ) : ResumableManager() { - private val callbackRef = WeakReference(callback) - - private fun deliverSuccess(credentials: Credentials) { - val cb = callbackRef.get() - if (cb != null) { - cb.onSuccess(credentials) - } else { - WebAuthProvider.pendingLoginResult = - WebAuthProvider.PendingResult.Success(credentials) - } - } - - private fun deliverFailure(error: AuthenticationException) { - val cb = callbackRef.get() - if (cb != null) { - cb.onFailure(error) - } else { - WebAuthProvider.pendingLoginResult = - WebAuthProvider.PendingResult.Failure(error) - } - } private val parameters: MutableMap private val headers: MutableMap @@ -91,7 +69,7 @@ internal class OAuthManager( try { addDPoPJWKParameters(parameters) } catch (ex: DPoPException) { - deliverFailure( + callback.onFailure( AuthenticationException( ex.message ?: "Error generating the JWK", ex @@ -120,7 +98,7 @@ internal class OAuthManager( AuthenticationException.ERROR_VALUE_AUTHENTICATION_CANCELED, "The user closed the browser app and the authentication was canceled." ) - deliverFailure(exception) + callback.onFailure(exception) return true } val values = CallbackHelper.getValuesFromUri(result.intentData) @@ -133,7 +111,7 @@ internal class OAuthManager( assertNoError(values[KEY_ERROR], values[KEY_ERROR_DESCRIPTION]) assertValidState(parameters[KEY_STATE]!!, values[KEY_STATE]) } catch (e: AuthenticationException) { - deliverFailure(e) + callback.onFailure(e) return true } @@ -146,14 +124,14 @@ internal class OAuthManager( credentials.idToken, object : Callback { override fun onSuccess(result: Void?) { - deliverSuccess(credentials) + callback.onSuccess(credentials) } override fun onFailure(error: Auth0Exception) { val wrappedError = AuthenticationException( ERROR_VALUE_ID_TOKEN_VALIDATION_FAILED, error ) - deliverFailure(wrappedError) + callback.onFailure(wrappedError) } }) } @@ -165,14 +143,14 @@ internal class OAuthManager( "Unable to complete authentication with PKCE. PKCE support can be enabled by setting Application Type to 'Native' and Token Endpoint Authentication Method to 'None' for this app at 'https://manage.auth0.com/#/applications/" + apiClient.clientId + "/settings'." ) } - deliverFailure(error) + callback.onFailure(error) } }) return true } public override fun failure(exception: AuthenticationException) { - deliverFailure(exception) + callback.onFailure(exception) } private fun assertValidIdToken( 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 1b138362b..fe8f17988 100644 --- a/auth0/src/main/java/com/auth0/android/provider/WebAuthProvider.kt +++ b/auth0/src/main/java/com/auth0/android/provider/WebAuthProvider.kt @@ -6,6 +6,7 @@ import android.net.Uri import android.os.Bundle import android.util.Log import androidx.annotation.VisibleForTesting +import androidx.lifecycle.LifecycleOwner import com.auth0.android.Auth0 import com.auth0.android.annotation.ExperimentalAuth0Api import com.auth0.android.authentication.AuthenticationException @@ -18,6 +19,7 @@ import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.withContext import java.util.Locale import java.util.concurrent.CopyOnWriteArraySet +import java.util.concurrent.atomic.AtomicReference import kotlin.coroutines.CoroutineContext import kotlin.coroutines.resume import kotlin.coroutines.resumeWithException @@ -49,15 +51,13 @@ public object WebAuthProvider { data class Failure(val error: E) : PendingResult() } - @Volatile - @JvmStatic - @get:VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) - internal var pendingLoginResult: PendingResult? = null + @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) + internal val pendingLoginResult = + AtomicReference?>(null) - @Volatile - @JvmStatic - @get:VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) - internal var pendingLogoutResult: PendingResult? = null + @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) + internal val pendingLogoutResult = + AtomicReference?>(null) /** * Check for and consume a pending login result that arrived during a configuration change. @@ -69,8 +69,7 @@ public object WebAuthProvider { */ @JvmStatic public fun consumePendingLoginResult(callback: Callback): Boolean { - val result = pendingLoginResult ?: return false - pendingLoginResult = null + val result = pendingLoginResult.getAndSet(null) ?: return false when (result) { is PendingResult.Success -> callback.onSuccess(result.result) is PendingResult.Failure -> callback.onFailure(result.error) @@ -89,8 +88,7 @@ public object WebAuthProvider { */ @JvmStatic public fun consumePendingLogoutResult(callback: Callback): Boolean { - val result = pendingLogoutResult ?: return false - pendingLogoutResult = null + val result = pendingLogoutResult.getAndSet(null) ?: return false when (result) { is PendingResult.Success -> callback.onSuccess(result.result) is PendingResult.Failure -> callback.onFailure(result.error) @@ -302,6 +300,27 @@ public object WebAuthProvider { * @see AuthenticationException.isAuthenticationCanceled */ public fun start(context: Context, callback: Callback) { + pendingLogoutResult.set(null) + + val effectiveCallback = if (context is LifecycleOwner) { + LifecycleAwareCallback( + inner = callback, + lifecycleOwner = context as LifecycleOwner, + onDetached = { _: Void?, error: AuthenticationException? -> + if (error != null) { + pendingLogoutResult.set(PendingResult.Failure(error)) + } else { + pendingLogoutResult.set(PendingResult.Success(null)) + } + } + ) + } else { + callback + } + startInternal(context, effectiveCallback) + } + + internal fun startInternal(context: Context, callback: Callback) { resetManagerInstance() if (!ctOptions.hasCompatibleBrowser(context.packageManager)) { val ex = AuthenticationException( @@ -346,7 +365,7 @@ public object WebAuthProvider { ) { return withContext(coroutineContext) { suspendCancellableCoroutine { continuation -> - start(context, object : Callback { + startInternal(context, object : Callback { override fun onSuccess(result: Void?) { continuation.resume(Unit) } @@ -652,6 +671,29 @@ public object WebAuthProvider { public fun start( context: Context, callback: Callback + ) { + pendingLoginResult.set(null) + val effectiveCallback = if (context is LifecycleOwner) { + LifecycleAwareCallback( + inner = callback, + lifecycleOwner = context as LifecycleOwner, + onDetached = { success: Credentials?, error: AuthenticationException? -> + if (success != null) { + pendingLoginResult.set(PendingResult.Success(success)) + } else if (error != null) { + pendingLoginResult.set(PendingResult.Failure(error)) + } + } + ) + } else { + callback + } + startInternal(context, effectiveCallback) + } + + internal fun startInternal( + context: Context, + callback: Callback ) { resetManagerInstance() if (!ctOptions.hasCompatibleBrowser(context.packageManager)) { @@ -725,7 +767,9 @@ public object WebAuthProvider { ): Credentials { return withContext(coroutineContext) { suspendCancellableCoroutine { continuation -> - start(context, object : Callback { + // Use startInternal directly — the anonymous callback captures only the + // coroutine continuation, not an Activity, so lifecycle wrapping is not needed + startInternal(context, object : Callback { override fun onSuccess(result: Credentials) { continuation.resume(result) } 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 25e5747a1..44a85204b 100644 --- a/auth0/src/test/java/com/auth0/android/provider/WebAuthProviderTest.kt +++ b/auth0/src/test/java/com/auth0/android/provider/WebAuthProviderTest.kt @@ -2,6 +2,8 @@ package com.auth0.android.provider import android.app.Activity import android.content.Context +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleOwner import android.content.Intent import android.net.Uri import android.os.Parcelable @@ -114,8 +116,8 @@ public class WebAuthProviderTest { `when`(mockKeyStore.hasKeyPair()).thenReturn(false) - WebAuthProvider.pendingLoginResult = null - WebAuthProvider.pendingLogoutResult = null + WebAuthProvider.pendingLoginResult.set(null) + WebAuthProvider.pendingLogoutResult.set(null) } @@ -3070,28 +3072,28 @@ public class WebAuthProviderTest { @Test public fun shouldConsumePendingLoginSuccessResult() { val credentials = Mockito.mock(Credentials::class.java) - WebAuthProvider.pendingLoginResult = WebAuthProvider.PendingResult.Success(credentials) + WebAuthProvider.pendingLoginResult.set(WebAuthProvider.PendingResult.Success(credentials)) val consumed = WebAuthProvider.consumePendingLoginResult(callback) Assert.assertTrue(consumed) verify(callback).onSuccess(credentials) - Assert.assertNull(WebAuthProvider.pendingLoginResult) + Assert.assertNull(WebAuthProvider.pendingLoginResult.get()) } @Test public fun shouldConsumePendingLoginFailureResult() { val error = AuthenticationException("test_error", "Test error description") - WebAuthProvider.pendingLoginResult = WebAuthProvider.PendingResult.Failure(error) + WebAuthProvider.pendingLoginResult.set(WebAuthProvider.PendingResult.Failure(error)) val consumed = WebAuthProvider.consumePendingLoginResult(callback) Assert.assertTrue(consumed) verify(callback).onFailure(error) - Assert.assertNull(WebAuthProvider.pendingLoginResult) + Assert.assertNull(WebAuthProvider.pendingLoginResult.get()) } @Test public fun shouldReturnFalseWhenNoPendingLoginResult() { - WebAuthProvider.pendingLoginResult = null + WebAuthProvider.pendingLoginResult.set(null) val consumed = WebAuthProvider.consumePendingLoginResult(callback) Assert.assertFalse(consumed) @@ -3102,7 +3104,7 @@ public class WebAuthProviderTest { @Test public fun shouldNotConsumeLoginResultTwice() { val credentials = Mockito.mock(Credentials::class.java) - WebAuthProvider.pendingLoginResult = WebAuthProvider.PendingResult.Success(credentials) + WebAuthProvider.pendingLoginResult.set(WebAuthProvider.PendingResult.Success(credentials)) Assert.assertTrue(WebAuthProvider.consumePendingLoginResult(callback)) Assert.assertFalse(WebAuthProvider.consumePendingLoginResult(callback)) @@ -3111,28 +3113,28 @@ public class WebAuthProviderTest { @Test public fun shouldConsumePendingLogoutSuccessResult() { - WebAuthProvider.pendingLogoutResult = WebAuthProvider.PendingResult.Success(null) + WebAuthProvider.pendingLogoutResult.set(WebAuthProvider.PendingResult.Success(null)) val consumed = WebAuthProvider.consumePendingLogoutResult(voidCallback) Assert.assertTrue(consumed) verify(voidCallback).onSuccess(null) - Assert.assertNull(WebAuthProvider.pendingLogoutResult) + Assert.assertNull(WebAuthProvider.pendingLogoutResult.get()) } @Test public fun shouldConsumePendingLogoutFailureResult() { val error = AuthenticationException("test_error", "Test error description") - WebAuthProvider.pendingLogoutResult = WebAuthProvider.PendingResult.Failure(error) + WebAuthProvider.pendingLogoutResult.set(WebAuthProvider.PendingResult.Failure(error)) val consumed = WebAuthProvider.consumePendingLogoutResult(voidCallback) Assert.assertTrue(consumed) verify(voidCallback).onFailure(error) - Assert.assertNull(WebAuthProvider.pendingLogoutResult) + Assert.assertNull(WebAuthProvider.pendingLogoutResult.get()) } @Test public fun shouldReturnFalseWhenNoPendingLogoutResult() { - WebAuthProvider.pendingLogoutResult = null + WebAuthProvider.pendingLogoutResult.set(null) val consumed = WebAuthProvider.consumePendingLogoutResult(voidCallback) Assert.assertFalse(consumed) @@ -3141,36 +3143,239 @@ public class WebAuthProviderTest { } @Test - public fun shouldCacheLoginResultWhenCallbackIsGarbageCollected() { - WebAuthProvider.pendingLoginResult = null + public fun shouldCacheLoginResultWhenLifecycleCallbackIsDetachedOnDestroy() { val credentials = Mockito.mock(Credentials::class.java) + WebAuthProvider.pendingLoginResult.set(null) + + val lifecycleOwner = Mockito.mock(LifecycleOwner::class.java) + val lifecycle = Mockito.mock(Lifecycle::class.java) + Mockito.`when`(lifecycleOwner.lifecycle).thenReturn(lifecycle) + + val lifecycleCallback = LifecycleAwareCallback( + inner = callback, + lifecycleOwner = lifecycleOwner, + onDetached = { success, error -> + if (success != null) WebAuthProvider.pendingLoginResult.set(WebAuthProvider.PendingResult.Success(success)) + else if (error != null) WebAuthProvider.pendingLoginResult.set(WebAuthProvider.PendingResult.Failure(error)) + } + ) + + lifecycleCallback.onDestroy(lifecycleOwner) + + lifecycleCallback.onSuccess(credentials) + + val pending = WebAuthProvider.pendingLoginResult.get() + Assert.assertNotNull(pending) + Assert.assertTrue(pending is WebAuthProvider.PendingResult.Success) + Assert.assertEquals(credentials, (pending as WebAuthProvider.PendingResult.Success).result) + verify(callback, Mockito.never()).onSuccess(any()) // old callback never called + } + + @Test + public fun shouldCacheLoginFailureWhenLifecycleCallbackIsDetachedOnDestroy() { + val error = AuthenticationException("canceled", "User canceled") + WebAuthProvider.pendingLoginResult.set(null) + val lifecycleOwner = Mockito.mock(LifecycleOwner::class.java) + val lifecycle = Mockito.mock(Lifecycle::class.java) + Mockito.`when`(lifecycleOwner.lifecycle).thenReturn(lifecycle) - var weakCallback: Callback? = - object : Callback { - override fun onSuccess(result: Credentials) {} - override fun onFailure(error: AuthenticationException) {} + val lifecycleCallback = LifecycleAwareCallback( + inner = callback, + lifecycleOwner = lifecycleOwner, + onDetached = { success, detachedError -> + if (success != null) WebAuthProvider.pendingLoginResult.set(WebAuthProvider.PendingResult.Success(success)) + else if (detachedError != null) WebAuthProvider.pendingLoginResult.set(WebAuthProvider.PendingResult.Failure(detachedError)) } - val manager = OAuthManager( - account, - weakCallback!!, - mapOf("response_type" to "code", "state" to "teststate", "nonce" to "testnonce"), - CustomTabsOptions.newBuilder().build() ) - @Suppress("UNUSED_VALUE") - weakCallback = null - System.gc() - Thread.sleep(100) - val exception = AuthenticationException( - AuthenticationException.ERROR_VALUE_AUTHENTICATION_CANCELED, - "The user closed the browser app and the authentication was canceled." + lifecycleCallback.onDestroy(lifecycleOwner) + lifecycleCallback.onFailure(error) + + val pending = WebAuthProvider.pendingLoginResult.get() + Assert.assertNotNull(pending) + Assert.assertTrue(pending is WebAuthProvider.PendingResult.Failure) + verify(callback, Mockito.never()).onFailure(any()) + } + + @Test + public fun shouldDeliverDirectlyWhenLifecycleCallbackIsAlive() { + val credentials = Mockito.mock(Credentials::class.java) + + val lifecycleOwner = Mockito.mock(LifecycleOwner::class.java) + val lifecycle = Mockito.mock(Lifecycle::class.java) + Mockito.`when`(lifecycleOwner.lifecycle).thenReturn(lifecycle) + + val lifecycleCallback = LifecycleAwareCallback( + inner = callback, + lifecycleOwner = lifecycleOwner, + onDetached = { success, error -> + if (success != null) WebAuthProvider.pendingLoginResult.set(WebAuthProvider.PendingResult.Success(success)) + else if (error != null) WebAuthProvider.pendingLoginResult.set(WebAuthProvider.PendingResult.Failure(error)) + } ) - manager.failure(exception) - val pending = WebAuthProvider.pendingLoginResult + lifecycleCallback.onSuccess(credentials) + + verify(callback).onSuccess(credentials) + Assert.assertNull(WebAuthProvider.pendingLoginResult.get()) + } + + @Test + public fun shouldDeliverFailureDirectlyWhenLifecycleCallbackIsAlive() { + val error = AuthenticationException("canceled", "User canceled") + + val lifecycleOwner = Mockito.mock(LifecycleOwner::class.java) + val lifecycle = Mockito.mock(Lifecycle::class.java) + Mockito.`when`(lifecycleOwner.lifecycle).thenReturn(lifecycle) + + val lifecycleCallback = LifecycleAwareCallback( + inner = callback, + lifecycleOwner = lifecycleOwner, + onDetached = { success, detachedError -> + if (success != null) WebAuthProvider.pendingLoginResult.set(WebAuthProvider.PendingResult.Success(success)) + else if (detachedError != null) WebAuthProvider.pendingLoginResult.set(WebAuthProvider.PendingResult.Failure(detachedError)) + } + ) + + lifecycleCallback.onFailure(error) + + verify(callback).onFailure(error) + Assert.assertNull(WebAuthProvider.pendingLoginResult.get()) + } + + @Test + public fun shouldCacheLogoutSuccessWhenLifecycleCallbackIsDetachedOnDestroy() { + WebAuthProvider.pendingLogoutResult.set(null) + + val lifecycleOwner = Mockito.mock(LifecycleOwner::class.java) + val lifecycle = Mockito.mock(Lifecycle::class.java) + Mockito.`when`(lifecycleOwner.lifecycle).thenReturn(lifecycle) + + val lifecycleCallback = LifecycleAwareCallback( + inner = voidCallback, + lifecycleOwner = lifecycleOwner, + onDetached = { success, error -> + if (error != null) WebAuthProvider.pendingLogoutResult.set(WebAuthProvider.PendingResult.Failure(error)) + else WebAuthProvider.pendingLogoutResult.set(WebAuthProvider.PendingResult.Success(success)) + } + ) + + lifecycleCallback.onDestroy(lifecycleOwner) + lifecycleCallback.onSuccess(null) + + val pending = WebAuthProvider.pendingLogoutResult.get() + Assert.assertNotNull(pending) + Assert.assertTrue(pending is WebAuthProvider.PendingResult.Success) + verify(voidCallback, Mockito.never()).onSuccess(any()) + } + + @Test + public fun shouldCacheLogoutFailureWhenLifecycleCallbackIsDetachedOnDestroy() { + val error = AuthenticationException("canceled", "User closed the browser") + WebAuthProvider.pendingLogoutResult.set(null) + + val lifecycleOwner = Mockito.mock(LifecycleOwner::class.java) + val lifecycle = Mockito.mock(Lifecycle::class.java) + Mockito.`when`(lifecycleOwner.lifecycle).thenReturn(lifecycle) + + val lifecycleCallback = LifecycleAwareCallback( + inner = voidCallback, + lifecycleOwner = lifecycleOwner, + onDetached = { success, detachedError -> + if (detachedError != null) WebAuthProvider.pendingLogoutResult.set(WebAuthProvider.PendingResult.Failure(detachedError)) + else WebAuthProvider.pendingLogoutResult.set(WebAuthProvider.PendingResult.Success(success)) + } + ) + + lifecycleCallback.onDestroy(lifecycleOwner) + lifecycleCallback.onFailure(error) + + val pending = WebAuthProvider.pendingLogoutResult.get() Assert.assertNotNull(pending) Assert.assertTrue(pending is WebAuthProvider.PendingResult.Failure) + verify(voidCallback, Mockito.never()).onFailure(any()) + } + + @Test + public fun shouldDeliverLogoutDirectlyWhenLifecycleCallbackIsAlive() { + val lifecycleOwner = Mockito.mock(LifecycleOwner::class.java) + val lifecycle = Mockito.mock(Lifecycle::class.java) + Mockito.`when`(lifecycleOwner.lifecycle).thenReturn(lifecycle) + + val lifecycleCallback = LifecycleAwareCallback( + inner = voidCallback, + lifecycleOwner = lifecycleOwner, + onDetached = { success, error -> + if (error != null) WebAuthProvider.pendingLogoutResult.set(WebAuthProvider.PendingResult.Failure(error)) + else WebAuthProvider.pendingLogoutResult.set(WebAuthProvider.PendingResult.Success(success)) + } + ) + + lifecycleCallback.onSuccess(null) + + verify(voidCallback).onSuccess(null) + Assert.assertNull(WebAuthProvider.pendingLogoutResult.get()) + } + + @Test + public fun shouldNotConsumeLogoutResultTwice() { + WebAuthProvider.pendingLogoutResult.set(WebAuthProvider.PendingResult.Success(null)) + + Assert.assertTrue(WebAuthProvider.consumePendingLogoutResult(voidCallback)) + Assert.assertFalse(WebAuthProvider.consumePendingLogoutResult(voidCallback)) + verify(voidCallback, times(1)).onSuccess(null) + } + + @Test + public fun shouldRegisterAsLifecycleObserverOnInit() { + val lifecycleOwner = Mockito.mock(LifecycleOwner::class.java) + val lifecycle = Mockito.mock(Lifecycle::class.java) + Mockito.`when`(lifecycleOwner.lifecycle).thenReturn(lifecycle) + + val lifecycleCallback = LifecycleAwareCallback( + inner = callback, + lifecycleOwner = lifecycleOwner, + onDetached = { _, _ -> } + ) + + verify(lifecycle).addObserver(lifecycleCallback) + } + + @Test + public fun shouldUnregisterLifecycleObserverOnDestroy() { + val lifecycleOwner = Mockito.mock(LifecycleOwner::class.java) + val lifecycle = Mockito.mock(Lifecycle::class.java) + Mockito.`when`(lifecycleOwner.lifecycle).thenReturn(lifecycle) + + val lifecycleCallback = LifecycleAwareCallback( + inner = callback, + lifecycleOwner = lifecycleOwner, + onDetached = { _, _ -> } + ) + + lifecycleCallback.onDestroy(lifecycleOwner) + + verify(lifecycle).removeObserver(lifecycleCallback) + } + + @Test + public fun shouldClearPendingLoginResultOnNewLoginStart() { + val staleCredentials = Mockito.mock(Credentials::class.java) + WebAuthProvider.pendingLoginResult.set(WebAuthProvider.PendingResult.Success(staleCredentials)) + + login(account).start(activity, callback) + + Assert.assertNull(WebAuthProvider.pendingLoginResult.get()) + } + + @Test + public fun shouldClearPendingLogoutResultOnNewLogoutStart() { + WebAuthProvider.pendingLogoutResult.set(WebAuthProvider.PendingResult.Success(null)) + + logout(account).start(activity, voidCallback) + + Assert.assertNull(WebAuthProvider.pendingLogoutResult.get()) } private companion object { From c0831d1bc6e2b7c07906bd053542241dc0580403 Mon Sep 17 00:00:00 2001 From: utkrishtS Date: Tue, 31 Mar 2026 17:25:25 +0530 Subject: [PATCH 03/10] updated Migration doc --- V4_MIGRATION_GUIDE.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/V4_MIGRATION_GUIDE.md b/V4_MIGRATION_GUIDE.md index 4abad7de1..68b5d8152 100644 --- a/V4_MIGRATION_GUIDE.md +++ b/V4_MIGRATION_GUIDE.md @@ -302,8 +302,10 @@ migrate to the Builder. ### Handling Configuration Changes During Authentication v4 fixes a memory leak and lost callback issue when the Activity is destroyed during authentication -(e.g. device rotation, locale change, dark mode toggle). The SDK now uses `WeakReference` for -callbacks, so destroyed Activities are properly garbage collected. +(e.g. device rotation, locale change, dark mode toggle). The SDK wraps the callback in a +`LifecycleAwareCallback` that observes the host Activity/Fragment lifecycle. When `onDestroy` fires, +the reference to the callback is immediately nulled out so the destroyed Activity is no longer held +in memory. If the authentication result arrives while the Activity is being recreated, it is cached internally. Use `consumePendingLoginResult()` or `consumePendingLogoutResult()` in your `onResume()` to recover it: From 61e92fe6a7c8f3db80e2bee46ff73e58d474c0ac Mon Sep 17 00:00:00 2001 From: utkrishtS Date: Thu, 2 Apr 2026 09:27:21 +0530 Subject: [PATCH 04/10] fix: handle config changes in WebAuth flow with unified attach() API --- V4_MIGRATION_GUIDE.md | 31 ++- .../provider/LifecycleAwareCallback.kt | 16 +- .../auth0/android/provider/WebAuthProvider.kt | 115 ++++++---- .../android/provider/WebAuthProviderTest.kt | 207 ++++++++++-------- 4 files changed, 214 insertions(+), 155 deletions(-) diff --git a/V4_MIGRATION_GUIDE.md b/V4_MIGRATION_GUIDE.md index 68b5d8152..9812dccaa 100644 --- a/V4_MIGRATION_GUIDE.md +++ b/V4_MIGRATION_GUIDE.md @@ -308,19 +308,25 @@ the reference to the callback is immediately nulled out so the destroyed Activit in memory. If the authentication result arrives while the Activity is being recreated, it is cached internally. -Use `consumePendingLoginResult()` or `consumePendingLogoutResult()` in your `onResume()` to recover it: +Use `WebAuthProvider.attach()` in your `onResume()` to recover it — this single call handles both +recovery scenarios and manages the callback lifecycle automatically: ```kotlin class LoginActivity : AppCompatActivity() { - private val callback = object : Callback { - override fun onSuccess(result: Credentials) { /* handle credentials */ } - override fun onFailure(error: AuthenticationException) { /* handle error */ } - } override fun onResume() { super.onResume() - // Recover result that arrived during configuration change - WebAuthProvider.consumePendingLoginResult(callback) + WebAuthProvider.attach( + lifecycleOwner = this, + loginCallback = object : Callback { + override fun onSuccess(result: Credentials) { /* handle credentials */ } + override fun onFailure(error: AuthenticationException) { /* handle error */ } + }, + logoutCallback = object : Callback { + override fun onSuccess(result: Void?) { /* handle logout */ } + override fun onFailure(error: AuthenticationException) { /* handle error */ } + } + ) } fun onLoginClick() { @@ -331,10 +337,17 @@ class LoginActivity : AppCompatActivity() { } ``` -For logout flows, use `WebAuthProvider.consumePendingLogoutResult(callback)` in the same way. +`attach()` covers both scenarios in one call: + +| Scenario | How it's handled | +|----------|-----------------| +| **Configuration change** (rotation, locale, dark mode) | Any result cached while the Activity was recreating is delivered immediately to the callback | +| **Process death** (system killed the app while browser was open) | `loginCallback` is registered as a listener and auto-removed when `lifecycleOwner` is destroyed — no manual `addCallback`/`removeCallback` calls needed | + +> **Note:** `logoutCallback` is optional — pass it only if your screen initiates logout flows. > **Note:** If you use the `suspend fun await()` API from a ViewModel coroutine scope, the -> Activity is never captured in the callback chain, so you do not need `consumePending*` calls. +> Activity is never captured in the callback chain, so you do not need `attach()` calls. > See the sample app for a ViewModel-based example. ## Getting Help diff --git a/auth0/src/main/java/com/auth0/android/provider/LifecycleAwareCallback.kt b/auth0/src/main/java/com/auth0/android/provider/LifecycleAwareCallback.kt index d9f37de94..52e5024ff 100644 --- a/auth0/src/main/java/com/auth0/android/provider/LifecycleAwareCallback.kt +++ b/auth0/src/main/java/com/auth0/android/provider/LifecycleAwareCallback.kt @@ -7,19 +7,19 @@ import com.auth0.android.callback.Callback /** * Wraps a user-provided callback and observes the Activity/Fragment lifecycle. - * When the host is destroyed (e.g. config change), [inner] is set to null so + * When the host is destroyed (e.g. config change), [delegateCallback] is set to null so * the destroyed Activity is no longer referenced by the SDK. * - * If a result arrives after [inner] has been cleared, the [onDetached] lambda - * is invoked to cache the result for later recovery via consumePending*Result(). + * If a result arrives after [delegateCallback] has been cleared, the [onDetached] lambda + * is invoked to cache the result for later recovery via resumePending*Result(). * * @param S the success type (Credentials for login, Void? for logout) - * @param inner the user's original callback + * @param delegateCallback the user's original callback * @param lifecycleOwner the Activity or Fragment whose lifecycle to observe * @param onDetached called when a result arrives but the callback is already detached */ internal class LifecycleAwareCallback( - @Volatile private var inner: Callback?, + private var delegateCallback: Callback?, lifecycleOwner: LifecycleOwner, private val onDetached: (success: S?, error: AuthenticationException?) -> Unit, ) : Callback, DefaultLifecycleObserver { @@ -29,7 +29,7 @@ internal class LifecycleAwareCallback( } override fun onSuccess(result: S) { - val cb = inner + val cb = delegateCallback if (cb != null) { cb.onSuccess(result) } else { @@ -38,7 +38,7 @@ internal class LifecycleAwareCallback( } override fun onFailure(error: AuthenticationException) { - val cb = inner + val cb = delegateCallback if (cb != null) { cb.onFailure(error) } else { @@ -47,7 +47,7 @@ internal class LifecycleAwareCallback( } override fun onDestroy(owner: LifecycleOwner) { - inner = null + delegateCallback = null owner.lifecycle.removeObserver(this) } } 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 fe8f17988..58ddd5f3a 100644 --- a/auth0/src/main/java/com/auth0/android/provider/WebAuthProvider.kt +++ b/auth0/src/main/java/com/auth0/android/provider/WebAuthProvider.kt @@ -5,7 +5,7 @@ import android.content.Intent import android.net.Uri import android.os.Bundle import android.util.Log -import androidx.annotation.VisibleForTesting +import androidx.lifecycle.DefaultLifecycleObserver import androidx.lifecycle.LifecycleOwner import com.auth0.android.Auth0 import com.auth0.android.annotation.ExperimentalAuth0Api @@ -34,10 +34,9 @@ public object WebAuthProvider { private val TAG: String? = WebAuthProvider::class.simpleName private const val KEY_BUNDLE_OAUTH_MANAGER_STATE = "oauth_manager_state" - private val callbacks = CopyOnWriteArraySet>() + internal val callbacks = CopyOnWriteArraySet>() @JvmStatic - @get:VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) internal var managerInstance: ResumableManager? = null private set @@ -46,62 +45,86 @@ public object WebAuthProvider { * the original callback was no longer reachable (e.g. Activity destroyed * during a configuration change). */ - internal sealed class PendingResult { - data class Success(val result: S) : PendingResult() - data class Failure(val error: E) : PendingResult() + internal sealed class PendingResult { + data class Success(val result: S) : PendingResult() + data class Failure(val error: AuthenticationException) : PendingResult() } - @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) - internal val pendingLoginResult = - AtomicReference?>(null) + internal val pendingLoginResult = AtomicReference?>(null) - @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) - internal val pendingLogoutResult = - AtomicReference?>(null) + internal val pendingLogoutResult = AtomicReference?>(null) /** - * Check for and consume a pending login result that arrived during a configuration change. - * Call this in your Activity's `onResume()` to recover results that were delivered while the - * Activity was being recreated (e.g. due to screen rotation). + * Attaches login (and optionally logout) callbacks for the duration of the given + * [lifecycleOwner]'s lifetime. Call this once in `onResume()` — it covers both recovery + * scenarios automatically: * - * @param callback the callback to deliver the pending result to - * @return true if a pending result was found and delivered, false otherwise - */ - @JvmStatic - public fun consumePendingLoginResult(callback: Callback): Boolean { - val result = pendingLoginResult.getAndSet(null) ?: return false - when (result) { - is PendingResult.Success -> callback.onSuccess(result.result) - is PendingResult.Failure -> callback.onFailure(result.error) - } - resetManagerInstance() - return true - } - - /** - * Check for and consume a pending logout result that arrived during a configuration change. - * Call this in your Activity's `onResume()` to recover results that were delivered while the - * Activity was being recreated (e.g. due to screen rotation). + * - **Configuration change** (rotation, locale, dark mode): if a login or logout result + * arrived while the Activity was being recreated, it is delivered immediately. + * - **Process death**: [loginCallback] is registered as a listener so that if the process + * was killed while the browser was open, the result is delivered when the Activity is + * restored. The callback is automatically unregistered when [lifecycleOwner] is destroyed, + * so there is no need to call [removeCallback] manually. + * + * ```kotlin + * override fun onResume() { + * super.onResume() + * WebAuthProvider.attach(this, loginCallback = callback, logoutCallback = voidCallback) + * } + * ``` * - * @param callback the callback to deliver the pending result to - * @return true if a pending result was found and delivered, false otherwise + * @param lifecycleOwner the Activity or Fragment whose lifecycle to observe + * @param loginCallback receives login results (both direct delivery and recovered results) + * @param logoutCallback receives logout results recovered after a configuration change */ @JvmStatic - public fun consumePendingLogoutResult(callback: Callback): Boolean { - val result = pendingLogoutResult.getAndSet(null) ?: return false - when (result) { - is PendingResult.Success -> callback.onSuccess(result.result) - is PendingResult.Failure -> callback.onFailure(result.error) - } - resetManagerInstance() - return true + public fun attach( + lifecycleOwner: LifecycleOwner, + loginCallback: Callback, + logoutCallback: Callback? = null, + ) { + // Process-death recovery: register and auto-remove on destroy + callbacks += loginCallback + lifecycleOwner.lifecycle.addObserver(object : DefaultLifecycleObserver { + override fun onDestroy(owner: LifecycleOwner) { + callbacks -= loginCallback + owner.lifecycle.removeObserver(this) + } + }) + + // Config-change recovery: deliver any result cached while Activity was recreating + pendingLoginResult.getAndSet(null)?.let { pending -> + when (pending) { + is PendingResult.Success -> loginCallback.onSuccess(pending.result) + is PendingResult.Failure -> loginCallback.onFailure(pending.error) + } + resetManagerInstance() + } + + logoutCallback?.let { cb -> + pendingLogoutResult.getAndSet(null)?.let { pending -> + when (pending) { + is PendingResult.Success -> cb.onSuccess(pending.result) + is PendingResult.Failure -> cb.onFailure(pending.error) + } + resetManagerInstance() + } + } } + @Deprecated( + message = "Use attach() instead — it registers the callback and auto-removes it when the lifecycle owner is destroyed.", + replaceWith = ReplaceWith("attach(lifecycleOwner, loginCallback = callback)") + ) @JvmStatic public fun addCallback(callback: Callback) { callbacks += callback } + @Deprecated( + message = "Use attach() instead — it auto-removes the callback when the lifecycle owner is destroyed.", + replaceWith = ReplaceWith("attach(lifecycleOwner, loginCallback = callback)") + ) @JvmStatic public fun removeCallback(callback: Callback) { callbacks -= callback @@ -198,7 +221,6 @@ public object WebAuthProvider { } @JvmStatic - @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) internal fun resetManagerInstance() { managerInstance = null } @@ -304,7 +326,7 @@ public object WebAuthProvider { val effectiveCallback = if (context is LifecycleOwner) { LifecycleAwareCallback( - inner = callback, + delegateCallback = callback, lifecycleOwner = context as LifecycleOwner, onDetached = { _: Void?, error: AuthenticationException? -> if (error != null) { @@ -638,7 +660,6 @@ public object WebAuthProvider { return this } - @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) internal fun withPKCE(pkce: PKCE): Builder { this.pkce = pkce return this @@ -675,7 +696,7 @@ public object WebAuthProvider { pendingLoginResult.set(null) val effectiveCallback = if (context is LifecycleOwner) { LifecycleAwareCallback( - inner = callback, + delegateCallback = callback, lifecycleOwner = context as LifecycleOwner, onDetached = { success: Credentials?, error: AuthenticationException? -> if (success != null) { 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 44a85204b..2163d8485 100644 --- a/auth0/src/test/java/com/auth0/android/provider/WebAuthProviderTest.kt +++ b/auth0/src/test/java/com/auth0/android/provider/WebAuthProviderTest.kt @@ -2,6 +2,7 @@ package com.auth0.android.provider import android.app.Activity import android.content.Context +import androidx.lifecycle.DefaultLifecycleObserver import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleOwner import android.content.Intent @@ -116,6 +117,7 @@ public class WebAuthProviderTest { `when`(mockKeyStore.hasKeyPair()).thenReturn(false) + // Clear any pending results left over from previous tests WebAuthProvider.pendingLoginResult.set(null) WebAuthProvider.pendingLogoutResult.set(null) } @@ -3069,79 +3071,6 @@ public class WebAuthProviderTest { verify(options, Mockito.never()).copyWithEphemeralBrowsing() } - @Test - public fun shouldConsumePendingLoginSuccessResult() { - val credentials = Mockito.mock(Credentials::class.java) - WebAuthProvider.pendingLoginResult.set(WebAuthProvider.PendingResult.Success(credentials)) - - val consumed = WebAuthProvider.consumePendingLoginResult(callback) - Assert.assertTrue(consumed) - verify(callback).onSuccess(credentials) - Assert.assertNull(WebAuthProvider.pendingLoginResult.get()) - } - - @Test - public fun shouldConsumePendingLoginFailureResult() { - val error = AuthenticationException("test_error", "Test error description") - WebAuthProvider.pendingLoginResult.set(WebAuthProvider.PendingResult.Failure(error)) - - val consumed = WebAuthProvider.consumePendingLoginResult(callback) - Assert.assertTrue(consumed) - verify(callback).onFailure(error) - Assert.assertNull(WebAuthProvider.pendingLoginResult.get()) - } - - @Test - public fun shouldReturnFalseWhenNoPendingLoginResult() { - WebAuthProvider.pendingLoginResult.set(null) - - val consumed = WebAuthProvider.consumePendingLoginResult(callback) - Assert.assertFalse(consumed) - verify(callback, Mockito.never()).onSuccess(any()) - verify(callback, Mockito.never()).onFailure(any()) - } - - @Test - public fun shouldNotConsumeLoginResultTwice() { - val credentials = Mockito.mock(Credentials::class.java) - WebAuthProvider.pendingLoginResult.set(WebAuthProvider.PendingResult.Success(credentials)) - - Assert.assertTrue(WebAuthProvider.consumePendingLoginResult(callback)) - Assert.assertFalse(WebAuthProvider.consumePendingLoginResult(callback)) - verify(callback, times(1)).onSuccess(credentials) - } - - @Test - public fun shouldConsumePendingLogoutSuccessResult() { - WebAuthProvider.pendingLogoutResult.set(WebAuthProvider.PendingResult.Success(null)) - - val consumed = WebAuthProvider.consumePendingLogoutResult(voidCallback) - Assert.assertTrue(consumed) - verify(voidCallback).onSuccess(null) - Assert.assertNull(WebAuthProvider.pendingLogoutResult.get()) - } - - @Test - public fun shouldConsumePendingLogoutFailureResult() { - val error = AuthenticationException("test_error", "Test error description") - WebAuthProvider.pendingLogoutResult.set(WebAuthProvider.PendingResult.Failure(error)) - - val consumed = WebAuthProvider.consumePendingLogoutResult(voidCallback) - Assert.assertTrue(consumed) - verify(voidCallback).onFailure(error) - Assert.assertNull(WebAuthProvider.pendingLogoutResult.get()) - } - - @Test - public fun shouldReturnFalseWhenNoPendingLogoutResult() { - WebAuthProvider.pendingLogoutResult.set(null) - - val consumed = WebAuthProvider.consumePendingLogoutResult(voidCallback) - Assert.assertFalse(consumed) - verify(voidCallback, Mockito.never()).onSuccess(any()) - verify(voidCallback, Mockito.never()).onFailure(any()) - } - @Test public fun shouldCacheLoginResultWhenLifecycleCallbackIsDetachedOnDestroy() { val credentials = Mockito.mock(Credentials::class.java) @@ -3152,7 +3081,7 @@ public class WebAuthProviderTest { Mockito.`when`(lifecycleOwner.lifecycle).thenReturn(lifecycle) val lifecycleCallback = LifecycleAwareCallback( - inner = callback, + delegateCallback = callback, lifecycleOwner = lifecycleOwner, onDetached = { success, error -> if (success != null) WebAuthProvider.pendingLoginResult.set(WebAuthProvider.PendingResult.Success(success)) @@ -3181,7 +3110,7 @@ public class WebAuthProviderTest { Mockito.`when`(lifecycleOwner.lifecycle).thenReturn(lifecycle) val lifecycleCallback = LifecycleAwareCallback( - inner = callback, + delegateCallback = callback, lifecycleOwner = lifecycleOwner, onDetached = { success, detachedError -> if (success != null) WebAuthProvider.pendingLoginResult.set(WebAuthProvider.PendingResult.Success(success)) @@ -3207,7 +3136,7 @@ public class WebAuthProviderTest { Mockito.`when`(lifecycleOwner.lifecycle).thenReturn(lifecycle) val lifecycleCallback = LifecycleAwareCallback( - inner = callback, + delegateCallback = callback, lifecycleOwner = lifecycleOwner, onDetached = { success, error -> if (success != null) WebAuthProvider.pendingLoginResult.set(WebAuthProvider.PendingResult.Success(success)) @@ -3230,7 +3159,7 @@ public class WebAuthProviderTest { Mockito.`when`(lifecycleOwner.lifecycle).thenReturn(lifecycle) val lifecycleCallback = LifecycleAwareCallback( - inner = callback, + delegateCallback = callback, lifecycleOwner = lifecycleOwner, onDetached = { success, detachedError -> if (success != null) WebAuthProvider.pendingLoginResult.set(WebAuthProvider.PendingResult.Success(success)) @@ -3253,7 +3182,7 @@ public class WebAuthProviderTest { Mockito.`when`(lifecycleOwner.lifecycle).thenReturn(lifecycle) val lifecycleCallback = LifecycleAwareCallback( - inner = voidCallback, + delegateCallback = voidCallback, lifecycleOwner = lifecycleOwner, onDetached = { success, error -> if (error != null) WebAuthProvider.pendingLogoutResult.set(WebAuthProvider.PendingResult.Failure(error)) @@ -3280,7 +3209,7 @@ public class WebAuthProviderTest { Mockito.`when`(lifecycleOwner.lifecycle).thenReturn(lifecycle) val lifecycleCallback = LifecycleAwareCallback( - inner = voidCallback, + delegateCallback = voidCallback, lifecycleOwner = lifecycleOwner, onDetached = { success, detachedError -> if (detachedError != null) WebAuthProvider.pendingLogoutResult.set(WebAuthProvider.PendingResult.Failure(detachedError)) @@ -3304,7 +3233,7 @@ public class WebAuthProviderTest { Mockito.`when`(lifecycleOwner.lifecycle).thenReturn(lifecycle) val lifecycleCallback = LifecycleAwareCallback( - inner = voidCallback, + delegateCallback = voidCallback, lifecycleOwner = lifecycleOwner, onDetached = { success, error -> if (error != null) WebAuthProvider.pendingLogoutResult.set(WebAuthProvider.PendingResult.Failure(error)) @@ -3318,15 +3247,6 @@ public class WebAuthProviderTest { Assert.assertNull(WebAuthProvider.pendingLogoutResult.get()) } - @Test - public fun shouldNotConsumeLogoutResultTwice() { - WebAuthProvider.pendingLogoutResult.set(WebAuthProvider.PendingResult.Success(null)) - - Assert.assertTrue(WebAuthProvider.consumePendingLogoutResult(voidCallback)) - Assert.assertFalse(WebAuthProvider.consumePendingLogoutResult(voidCallback)) - verify(voidCallback, times(1)).onSuccess(null) - } - @Test public fun shouldRegisterAsLifecycleObserverOnInit() { val lifecycleOwner = Mockito.mock(LifecycleOwner::class.java) @@ -3334,7 +3254,7 @@ public class WebAuthProviderTest { Mockito.`when`(lifecycleOwner.lifecycle).thenReturn(lifecycle) val lifecycleCallback = LifecycleAwareCallback( - inner = callback, + delegateCallback = callback, lifecycleOwner = lifecycleOwner, onDetached = { _, _ -> } ) @@ -3349,7 +3269,7 @@ public class WebAuthProviderTest { Mockito.`when`(lifecycleOwner.lifecycle).thenReturn(lifecycle) val lifecycleCallback = LifecycleAwareCallback( - inner = callback, + delegateCallback = callback, lifecycleOwner = lifecycleOwner, onDetached = { _, _ -> } ) @@ -3378,6 +3298,111 @@ public class WebAuthProviderTest { Assert.assertNull(WebAuthProvider.pendingLogoutResult.get()) } + + @Test + public fun shouldDeliverPendingLoginResultOnAttach() { + val credentials = Mockito.mock(Credentials::class.java) + WebAuthProvider.pendingLoginResult.set(WebAuthProvider.PendingResult.Success(credentials)) + + val lifecycleOwner = Mockito.mock(LifecycleOwner::class.java) + val lifecycle = Mockito.mock(Lifecycle::class.java) + Mockito.`when`(lifecycleOwner.lifecycle).thenReturn(lifecycle) + + WebAuthProvider.attach(lifecycleOwner, loginCallback = callback) + + verify(callback).onSuccess(credentials) + Assert.assertNull(WebAuthProvider.pendingLoginResult.get()) + } + + @Test + public fun shouldDeliverPendingLoginFailureOnAttach() { + val error = AuthenticationException("canceled", "User canceled") + WebAuthProvider.pendingLoginResult.set(WebAuthProvider.PendingResult.Failure(error)) + + val lifecycleOwner = Mockito.mock(LifecycleOwner::class.java) + val lifecycle = Mockito.mock(Lifecycle::class.java) + Mockito.`when`(lifecycleOwner.lifecycle).thenReturn(lifecycle) + + WebAuthProvider.attach(lifecycleOwner, loginCallback = callback) + + verify(callback).onFailure(error) + Assert.assertNull(WebAuthProvider.pendingLoginResult.get()) + } + + @Test + public fun shouldDeliverPendingLogoutResultOnAttach() { + WebAuthProvider.pendingLogoutResult.set(WebAuthProvider.PendingResult.Success(null)) + + val lifecycleOwner = Mockito.mock(LifecycleOwner::class.java) + val lifecycle = Mockito.mock(Lifecycle::class.java) + Mockito.`when`(lifecycleOwner.lifecycle).thenReturn(lifecycle) + + WebAuthProvider.attach(lifecycleOwner, loginCallback = callback, logoutCallback = voidCallback) + + verify(voidCallback).onSuccess(null) + Assert.assertNull(WebAuthProvider.pendingLogoutResult.get()) + } + + @Test + public fun shouldDeliverPendingLogoutFailureOnAttach() { + val error = AuthenticationException("canceled", "User closed the browser") + WebAuthProvider.pendingLogoutResult.set(WebAuthProvider.PendingResult.Failure(error)) + + val lifecycleOwner = Mockito.mock(LifecycleOwner::class.java) + val lifecycle = Mockito.mock(Lifecycle::class.java) + Mockito.`when`(lifecycleOwner.lifecycle).thenReturn(lifecycle) + + WebAuthProvider.attach(lifecycleOwner, loginCallback = callback, logoutCallback = voidCallback) + + verify(voidCallback).onFailure(error) + Assert.assertNull(WebAuthProvider.pendingLogoutResult.get()) + } + + @Test + public fun shouldRegisterLoginCallbackForProcessDeathOnAttach() { + val lifecycleOwner = Mockito.mock(LifecycleOwner::class.java) + val lifecycle = Mockito.mock(Lifecycle::class.java) + Mockito.`when`(lifecycleOwner.lifecycle).thenReturn(lifecycle) + + WebAuthProvider.attach(lifecycleOwner, loginCallback = callback) + + Assert.assertTrue(WebAuthProvider.callbacks.contains(callback)) + } + + @Test + public fun shouldAutoRemoveLoginCallbackOnDestroyAfterAttach() { + val lifecycleOwner = Mockito.mock(LifecycleOwner::class.java) + val lifecycle = Mockito.mock(Lifecycle::class.java) + Mockito.`when`(lifecycleOwner.lifecycle).thenReturn(lifecycle) + + // Capture the observer registered by attach() + val observerCaptor = argumentCaptor() + WebAuthProvider.attach(lifecycleOwner, loginCallback = callback) + verify(lifecycle).addObserver(observerCaptor.capture()) + + Assert.assertTrue(WebAuthProvider.callbacks.contains(callback)) + + // Simulate onDestroy + observerCaptor.firstValue.onDestroy(lifecycleOwner) + + Assert.assertFalse(WebAuthProvider.callbacks.contains(callback)) + } + + @Test + public fun shouldNotDeliverLogoutResultWhenNoLogoutCallbackPassedToAttach() { + WebAuthProvider.pendingLogoutResult.set(WebAuthProvider.PendingResult.Success(null)) + + val lifecycleOwner = Mockito.mock(LifecycleOwner::class.java) + val lifecycle = Mockito.mock(Lifecycle::class.java) + Mockito.`when`(lifecycleOwner.lifecycle).thenReturn(lifecycle) + + // attach without logoutCallback — pending logout result should stay untouched + WebAuthProvider.attach(lifecycleOwner, loginCallback = callback) + + verify(voidCallback, Mockito.never()).onSuccess(any()) + Assert.assertNotNull(WebAuthProvider.pendingLogoutResult.get()) + } + private companion object { private const val KEY_STATE = "state" private const val KEY_NONCE = "nonce" From 6ed2d1fd1ad50d047fd3b21eaf105d2d667f19b6 Mon Sep 17 00:00:00 2001 From: utkrishtS Date: Thu, 2 Apr 2026 09:46:32 +0530 Subject: [PATCH 05/10] updated example.md file --- EXAMPLES.md | 47 ++++++++++++++++++++++++----------------------- 1 file changed, 24 insertions(+), 23 deletions(-) diff --git a/EXAMPLES.md b/EXAMPLES.md index dd83646bf..b5b84d3ee 100644 --- a/EXAMPLES.md +++ b/EXAMPLES.md @@ -327,34 +327,35 @@ WebAuthProvider.logout(account) ## Handling Configuration Changes During Authentication -When the Activity is destroyed during authentication due to a configuration change (e.g. device rotation, locale change, dark mode toggle), the SDK caches the authentication result internally. Use `consumePendingLoginResult()` or `consumePendingLogoutResult()` in your `onResume()` to recover it. +When the Activity is destroyed during authentication due to a configuration change (e.g. device rotation, locale change, dark mode toggle), the SDK caches the authentication result internally. Call `WebAuthProvider.attach()` in your `onResume()` to recover it. This single call handles both recovery scenarios: + +- **Configuration change**: delivers any cached result immediately to the callback +- **Process death**: registers `loginCallback` as a listener and auto-removes it when the Activity is destroyed ```kotlin class LoginActivity : AppCompatActivity() { - private val loginCallback = object : Callback { - override fun onSuccess(result: Credentials) { - // Handle successful login - } - override fun onFailure(error: AuthenticationException) { - // Handle error - } - } - - private val logoutCallback = object : Callback { - override fun onSuccess(result: Void?) { - // Handle successful logout - } - override fun onFailure(error: AuthenticationException) { - // Handle error - } - } - override fun onResume() { super.onResume() - // Recover any result that arrived while the Activity was being recreated - WebAuthProvider.consumePendingLoginResult(loginCallback) - WebAuthProvider.consumePendingLogoutResult(logoutCallback) + WebAuthProvider.attach( + lifecycleOwner = this, + loginCallback = object : Callback { + override fun onSuccess(result: Credentials) { + // Handle successful login + } + override fun onFailure(error: AuthenticationException) { + // Handle error + } + }, + logoutCallback = object : Callback { + override fun onSuccess(result: Void?) { + // Handle successful logout + } + override fun onFailure(error: AuthenticationException) { + // Handle error + } + } + ) } fun onLoginClick() { @@ -372,7 +373,7 @@ class LoginActivity : AppCompatActivity() { ``` > [!NOTE] -> If you use the `suspend fun await()` API from a ViewModel coroutine scope, the Activity is never captured in the callback chain, so you do not need `consumePending*` calls. +> If you use the `suspend fun await()` API from a ViewModel coroutine scope, the Activity is never captured in the callback chain, so you do not need `attach()` calls. ## Authentication API From 6b7675c93d852073c1522a42a323513b03150ce5 Mon Sep 17 00:00:00 2001 From: utkrishtS Date: Thu, 2 Apr 2026 15:30:40 +0530 Subject: [PATCH 06/10] refactor: rename attach() to registerCallbacks(), deliver pending results on onResume, and make LogoutBuilder.startInternal private --- EXAMPLES.md | 12 ++-- V4_MIGRATION_GUIDE.md | 14 ++-- .../auth0/android/provider/WebAuthProvider.kt | 69 ++++++++++--------- .../android/provider/WebAuthProviderTest.kt | 53 +++++++++----- 4 files changed, 85 insertions(+), 63 deletions(-) diff --git a/EXAMPLES.md b/EXAMPLES.md index b5b84d3ee..a2bc7d5d3 100644 --- a/EXAMPLES.md +++ b/EXAMPLES.md @@ -327,17 +327,17 @@ WebAuthProvider.logout(account) ## Handling Configuration Changes During Authentication -When the Activity is destroyed during authentication due to a configuration change (e.g. device rotation, locale change, dark mode toggle), the SDK caches the authentication result internally. Call `WebAuthProvider.attach()` in your `onResume()` to recover it. This single call handles both recovery scenarios: +When the Activity is destroyed during authentication due to a configuration change (e.g. device rotation, locale change, dark mode toggle), the SDK caches the authentication result internally. Call `WebAuthProvider.registerCallbacks()` once in your `onCreate()` to recover it. This single call handles both recovery scenarios: -- **Configuration change**: delivers any cached result immediately to the callback +- **Configuration change**: delivers any cached result on the next `onResume` to the callback - **Process death**: registers `loginCallback` as a listener and auto-removes it when the Activity is destroyed ```kotlin class LoginActivity : AppCompatActivity() { - override fun onResume() { - super.onResume() - WebAuthProvider.attach( + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + WebAuthProvider.registerCallbacks( lifecycleOwner = this, loginCallback = object : Callback { override fun onSuccess(result: Credentials) { @@ -373,7 +373,7 @@ class LoginActivity : AppCompatActivity() { ``` > [!NOTE] -> If you use the `suspend fun await()` API from a ViewModel coroutine scope, the Activity is never captured in the callback chain, so you do not need `attach()` calls. +> If you use the `suspend fun await()` API from a ViewModel coroutine scope, the Activity is never captured in the callback chain, so you do not need `registerCallbacks()` calls. ## Authentication API diff --git a/V4_MIGRATION_GUIDE.md b/V4_MIGRATION_GUIDE.md index 9812dccaa..e1a71d265 100644 --- a/V4_MIGRATION_GUIDE.md +++ b/V4_MIGRATION_GUIDE.md @@ -308,15 +308,15 @@ the reference to the callback is immediately nulled out so the destroyed Activit in memory. If the authentication result arrives while the Activity is being recreated, it is cached internally. -Use `WebAuthProvider.attach()` in your `onResume()` to recover it — this single call handles both +Call `WebAuthProvider.registerCallbacks()` once in your `onCreate()` — this single call handles both recovery scenarios and manages the callback lifecycle automatically: ```kotlin class LoginActivity : AppCompatActivity() { - override fun onResume() { - super.onResume() - WebAuthProvider.attach( + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + WebAuthProvider.registerCallbacks( lifecycleOwner = this, loginCallback = object : Callback { override fun onSuccess(result: Credentials) { /* handle credentials */ } @@ -337,17 +337,17 @@ class LoginActivity : AppCompatActivity() { } ``` -`attach()` covers both scenarios in one call: +`registerCallbacks()` covers both scenarios in one call: | Scenario | How it's handled | |----------|-----------------| -| **Configuration change** (rotation, locale, dark mode) | Any result cached while the Activity was recreating is delivered immediately to the callback | +| **Configuration change** (rotation, locale, dark mode) | Any result cached while the Activity was recreating is delivered on the next `onResume` | | **Process death** (system killed the app while browser was open) | `loginCallback` is registered as a listener and auto-removed when `lifecycleOwner` is destroyed — no manual `addCallback`/`removeCallback` calls needed | > **Note:** `logoutCallback` is optional — pass it only if your screen initiates logout flows. > **Note:** If you use the `suspend fun await()` API from a ViewModel coroutine scope, the -> Activity is never captured in the callback chain, so you do not need `attach()` calls. +> Activity is never captured in the callback chain, so you do not need `registerCallbacks()` calls. > See the sample app for a ViewModel-based example. ## Getting Help 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 58ddd5f3a..e1f4a2832 100644 --- a/auth0/src/main/java/com/auth0/android/provider/WebAuthProvider.kt +++ b/auth0/src/main/java/com/auth0/android/provider/WebAuthProvider.kt @@ -55,21 +55,21 @@ public object WebAuthProvider { internal val pendingLogoutResult = AtomicReference?>(null) /** - * Attaches login (and optionally logout) callbacks for the duration of the given - * [lifecycleOwner]'s lifetime. Call this once in `onResume()` — it covers both recovery + * Registers login (and optionally logout) callbacks for the duration of the given + * [lifecycleOwner]'s lifetime. Call this once in `onCreate()` — it covers both recovery * scenarios automatically: * - * - **Configuration change** (rotation, locale, dark mode): if a login or logout result - * arrived while the Activity was being recreated, it is delivered immediately. - * - **Process death**: [loginCallback] is registered as a listener so that if the process + * - **Process death**: [loginCallback] is registered immediately so that if the process * was killed while the browser was open, the result is delivered when the Activity is * restored. The callback is automatically unregistered when [lifecycleOwner] is destroyed, * so there is no need to call [removeCallback] manually. + * - **Configuration change** (rotation, locale, dark mode): any login or logout result + * that arrived while the Activity was being recreated is delivered on the next `onResume`. * * ```kotlin - * override fun onResume() { - * super.onResume() - * WebAuthProvider.attach(this, loginCallback = callback, logoutCallback = voidCallback) + * override fun onCreate(savedInstanceState: Bundle?) { + * super.onCreate(savedInstanceState) + * WebAuthProvider.registerCallbacks(this, loginCallback = callback, logoutCallback = voidCallback) * } * ``` * @@ -78,43 +78,44 @@ public object WebAuthProvider { * @param logoutCallback receives logout results recovered after a configuration change */ @JvmStatic - public fun attach( + public fun registerCallbacks( lifecycleOwner: LifecycleOwner, loginCallback: Callback, logoutCallback: Callback? = null, ) { - // Process-death recovery: register and auto-remove on destroy + // Process-death recovery: register immediately so result is routed here on restore callbacks += loginCallback lifecycleOwner.lifecycle.addObserver(object : DefaultLifecycleObserver { + override fun onResume(owner: LifecycleOwner) { + // Config-change recovery: deliver any result cached while Activity was recreating + pendingLoginResult.getAndSet(null)?.let { pending -> + when (pending) { + is PendingResult.Success -> loginCallback.onSuccess(pending.result) + is PendingResult.Failure -> loginCallback.onFailure(pending.error) + } + resetManagerInstance() + } + logoutCallback?.let { cb -> + pendingLogoutResult.getAndSet(null)?.let { pending -> + when (pending) { + is PendingResult.Success -> cb.onSuccess(pending.result) + is PendingResult.Failure -> cb.onFailure(pending.error) + } + resetManagerInstance() + } + } + } + override fun onDestroy(owner: LifecycleOwner) { callbacks -= loginCallback owner.lifecycle.removeObserver(this) } }) - - // Config-change recovery: deliver any result cached while Activity was recreating - pendingLoginResult.getAndSet(null)?.let { pending -> - when (pending) { - is PendingResult.Success -> loginCallback.onSuccess(pending.result) - is PendingResult.Failure -> loginCallback.onFailure(pending.error) - } - resetManagerInstance() - } - - logoutCallback?.let { cb -> - pendingLogoutResult.getAndSet(null)?.let { pending -> - when (pending) { - is PendingResult.Success -> cb.onSuccess(pending.result) - is PendingResult.Failure -> cb.onFailure(pending.error) - } - resetManagerInstance() - } - } } @Deprecated( - message = "Use attach() instead — it registers the callback and auto-removes it when the lifecycle owner is destroyed.", - replaceWith = ReplaceWith("attach(lifecycleOwner, loginCallback = callback)") + message = "Use registerCallbacks() instead — it registers the callback and auto-removes it when the lifecycle owner is destroyed.", + replaceWith = ReplaceWith("registerCallbacks(lifecycleOwner, loginCallback = callback)") ) @JvmStatic public fun addCallback(callback: Callback) { @@ -122,8 +123,8 @@ public object WebAuthProvider { } @Deprecated( - message = "Use attach() instead — it auto-removes the callback when the lifecycle owner is destroyed.", - replaceWith = ReplaceWith("attach(lifecycleOwner, loginCallback = callback)") + message = "Use registerCallbacks() instead — it auto-removes the callback when the lifecycle owner is destroyed.", + replaceWith = ReplaceWith("registerCallbacks(lifecycleOwner, loginCallback = callback)") ) @JvmStatic public fun removeCallback(callback: Callback) { @@ -342,7 +343,7 @@ public object WebAuthProvider { startInternal(context, effectiveCallback) } - internal fun startInternal(context: Context, callback: Callback) { + private fun startInternal(context: Context, callback: Callback) { resetManagerInstance() if (!ctOptions.hasCompatibleBrowser(context.packageManager)) { val ex = AuthenticationException( 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 2163d8485..8b20e034f 100644 --- a/auth0/src/test/java/com/auth0/android/provider/WebAuthProviderTest.kt +++ b/auth0/src/test/java/com/auth0/android/provider/WebAuthProviderTest.kt @@ -3300,7 +3300,7 @@ public class WebAuthProviderTest { @Test - public fun shouldDeliverPendingLoginResultOnAttach() { + public fun shouldDeliverPendingLoginResultOnResume() { val credentials = Mockito.mock(Credentials::class.java) WebAuthProvider.pendingLoginResult.set(WebAuthProvider.PendingResult.Success(credentials)) @@ -3308,14 +3308,20 @@ public class WebAuthProviderTest { val lifecycle = Mockito.mock(Lifecycle::class.java) Mockito.`when`(lifecycleOwner.lifecycle).thenReturn(lifecycle) - WebAuthProvider.attach(lifecycleOwner, loginCallback = callback) + val observerCaptor = argumentCaptor() + WebAuthProvider.registerCallbacks(lifecycleOwner, loginCallback = callback) + verify(lifecycle).addObserver(observerCaptor.capture()) + + // Pending result is delivered on onResume, not immediately + verify(callback, Mockito.never()).onSuccess(any()) + observerCaptor.firstValue.onResume(lifecycleOwner) verify(callback).onSuccess(credentials) Assert.assertNull(WebAuthProvider.pendingLoginResult.get()) } @Test - public fun shouldDeliverPendingLoginFailureOnAttach() { + public fun shouldDeliverPendingLoginFailureOnResume() { val error = AuthenticationException("canceled", "User canceled") WebAuthProvider.pendingLoginResult.set(WebAuthProvider.PendingResult.Failure(error)) @@ -3323,28 +3329,36 @@ public class WebAuthProviderTest { val lifecycle = Mockito.mock(Lifecycle::class.java) Mockito.`when`(lifecycleOwner.lifecycle).thenReturn(lifecycle) - WebAuthProvider.attach(lifecycleOwner, loginCallback = callback) + val observerCaptor = argumentCaptor() + WebAuthProvider.registerCallbacks(lifecycleOwner, loginCallback = callback) + verify(lifecycle).addObserver(observerCaptor.capture()) + + observerCaptor.firstValue.onResume(lifecycleOwner) verify(callback).onFailure(error) Assert.assertNull(WebAuthProvider.pendingLoginResult.get()) } @Test - public fun shouldDeliverPendingLogoutResultOnAttach() { + public fun shouldDeliverPendingLogoutResultOnResume() { WebAuthProvider.pendingLogoutResult.set(WebAuthProvider.PendingResult.Success(null)) val lifecycleOwner = Mockito.mock(LifecycleOwner::class.java) val lifecycle = Mockito.mock(Lifecycle::class.java) Mockito.`when`(lifecycleOwner.lifecycle).thenReturn(lifecycle) - WebAuthProvider.attach(lifecycleOwner, loginCallback = callback, logoutCallback = voidCallback) + val observerCaptor = argumentCaptor() + WebAuthProvider.registerCallbacks(lifecycleOwner, loginCallback = callback, logoutCallback = voidCallback) + verify(lifecycle).addObserver(observerCaptor.capture()) + + observerCaptor.firstValue.onResume(lifecycleOwner) verify(voidCallback).onSuccess(null) Assert.assertNull(WebAuthProvider.pendingLogoutResult.get()) } @Test - public fun shouldDeliverPendingLogoutFailureOnAttach() { + public fun shouldDeliverPendingLogoutFailureOnResume() { val error = AuthenticationException("canceled", "User closed the browser") WebAuthProvider.pendingLogoutResult.set(WebAuthProvider.PendingResult.Failure(error)) @@ -3352,32 +3366,35 @@ public class WebAuthProviderTest { val lifecycle = Mockito.mock(Lifecycle::class.java) Mockito.`when`(lifecycleOwner.lifecycle).thenReturn(lifecycle) - WebAuthProvider.attach(lifecycleOwner, loginCallback = callback, logoutCallback = voidCallback) + val observerCaptor = argumentCaptor() + WebAuthProvider.registerCallbacks(lifecycleOwner, loginCallback = callback, logoutCallback = voidCallback) + verify(lifecycle).addObserver(observerCaptor.capture()) + + observerCaptor.firstValue.onResume(lifecycleOwner) verify(voidCallback).onFailure(error) Assert.assertNull(WebAuthProvider.pendingLogoutResult.get()) } @Test - public fun shouldRegisterLoginCallbackForProcessDeathOnAttach() { + public fun shouldRegisterLoginCallbackForProcessDeathOnRegisterCallbacks() { val lifecycleOwner = Mockito.mock(LifecycleOwner::class.java) val lifecycle = Mockito.mock(Lifecycle::class.java) Mockito.`when`(lifecycleOwner.lifecycle).thenReturn(lifecycle) - WebAuthProvider.attach(lifecycleOwner, loginCallback = callback) + WebAuthProvider.registerCallbacks(lifecycleOwner, loginCallback = callback) Assert.assertTrue(WebAuthProvider.callbacks.contains(callback)) } @Test - public fun shouldAutoRemoveLoginCallbackOnDestroyAfterAttach() { + public fun shouldAutoRemoveLoginCallbackOnDestroyAfterRegisterCallbacks() { val lifecycleOwner = Mockito.mock(LifecycleOwner::class.java) val lifecycle = Mockito.mock(Lifecycle::class.java) Mockito.`when`(lifecycleOwner.lifecycle).thenReturn(lifecycle) - // Capture the observer registered by attach() val observerCaptor = argumentCaptor() - WebAuthProvider.attach(lifecycleOwner, loginCallback = callback) + WebAuthProvider.registerCallbacks(lifecycleOwner, loginCallback = callback) verify(lifecycle).addObserver(observerCaptor.capture()) Assert.assertTrue(WebAuthProvider.callbacks.contains(callback)) @@ -3389,15 +3406,19 @@ public class WebAuthProviderTest { } @Test - public fun shouldNotDeliverLogoutResultWhenNoLogoutCallbackPassedToAttach() { + public fun shouldNotDeliverLogoutResultWhenNoLogoutCallbackPassedToRegisterCallbacks() { WebAuthProvider.pendingLogoutResult.set(WebAuthProvider.PendingResult.Success(null)) val lifecycleOwner = Mockito.mock(LifecycleOwner::class.java) val lifecycle = Mockito.mock(Lifecycle::class.java) Mockito.`when`(lifecycleOwner.lifecycle).thenReturn(lifecycle) - // attach without logoutCallback — pending logout result should stay untouched - WebAuthProvider.attach(lifecycleOwner, loginCallback = callback) + val observerCaptor = argumentCaptor() + WebAuthProvider.registerCallbacks(lifecycleOwner, loginCallback = callback) + verify(lifecycle).addObserver(observerCaptor.capture()) + + // registerCallbacks without logoutCallback — pending logout result should stay untouched + observerCaptor.firstValue.onResume(lifecycleOwner) verify(voidCallback, Mockito.never()).onSuccess(any()) Assert.assertNotNull(WebAuthProvider.pendingLogoutResult.get()) From ad0df64193a3c9f3b4955a8651a6c60d325dc1c2 Mon Sep 17 00:00:00 2001 From: utkrishtS Date: Thu, 2 Apr 2026 15:51:05 +0530 Subject: [PATCH 07/10] make pendingLoginResult and pendingLogoutResult private, use reflection in tests --- .../auth0/android/provider/WebAuthProvider.kt | 4 +- .../android/provider/WebAuthProviderTest.kt | 141 ++++++++++-------- 2 files changed, 84 insertions(+), 61 deletions(-) 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 e1f4a2832..179399933 100644 --- a/auth0/src/main/java/com/auth0/android/provider/WebAuthProvider.kt +++ b/auth0/src/main/java/com/auth0/android/provider/WebAuthProvider.kt @@ -50,9 +50,9 @@ public object WebAuthProvider { data class Failure(val error: AuthenticationException) : PendingResult() } - internal val pendingLoginResult = AtomicReference?>(null) + private val pendingLoginResult = AtomicReference?>(null) - internal val pendingLogoutResult = AtomicReference?>(null) + private val pendingLogoutResult = AtomicReference?>(null) /** * Registers login (and optionally logout) callbacks for the duration of the given 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 8b20e034f..5a302f39c 100644 --- a/auth0/src/test/java/com/auth0/android/provider/WebAuthProviderTest.kt +++ b/auth0/src/test/java/com/auth0/android/provider/WebAuthProviderTest.kt @@ -65,6 +65,7 @@ import java.io.InputStream import java.nio.file.Files import java.nio.file.Paths import java.util.* +import java.util.concurrent.atomic.AtomicReference @RunWith(RobolectricTestRunner::class) @Config(shadows = [ThreadSwitcherShadow::class]) @@ -118,8 +119,8 @@ public class WebAuthProviderTest { `when`(mockKeyStore.hasKeyPair()).thenReturn(false) // Clear any pending results left over from previous tests - WebAuthProvider.pendingLoginResult.set(null) - WebAuthProvider.pendingLogoutResult.set(null) + setPendingLoginResult(null) + setPendingLogoutResult(null) } @@ -3074,7 +3075,8 @@ public class WebAuthProviderTest { @Test public fun shouldCacheLoginResultWhenLifecycleCallbackIsDetachedOnDestroy() { val credentials = Mockito.mock(Credentials::class.java) - WebAuthProvider.pendingLoginResult.set(null) + var capturedSuccess: Credentials? = null + var capturedError: AuthenticationException? = null val lifecycleOwner = Mockito.mock(LifecycleOwner::class.java) val lifecycle = Mockito.mock(Lifecycle::class.java) @@ -3084,26 +3086,24 @@ public class WebAuthProviderTest { delegateCallback = callback, lifecycleOwner = lifecycleOwner, onDetached = { success, error -> - if (success != null) WebAuthProvider.pendingLoginResult.set(WebAuthProvider.PendingResult.Success(success)) - else if (error != null) WebAuthProvider.pendingLoginResult.set(WebAuthProvider.PendingResult.Failure(error)) + capturedSuccess = success + capturedError = error } ) lifecycleCallback.onDestroy(lifecycleOwner) - lifecycleCallback.onSuccess(credentials) - val pending = WebAuthProvider.pendingLoginResult.get() - Assert.assertNotNull(pending) - Assert.assertTrue(pending is WebAuthProvider.PendingResult.Success) - Assert.assertEquals(credentials, (pending as WebAuthProvider.PendingResult.Success).result) + Assert.assertEquals(credentials, capturedSuccess) + Assert.assertNull(capturedError) verify(callback, Mockito.never()).onSuccess(any()) // old callback never called } @Test public fun shouldCacheLoginFailureWhenLifecycleCallbackIsDetachedOnDestroy() { val error = AuthenticationException("canceled", "User canceled") - WebAuthProvider.pendingLoginResult.set(null) + var capturedSuccess: Credentials? = null + var capturedError: AuthenticationException? = null val lifecycleOwner = Mockito.mock(LifecycleOwner::class.java) val lifecycle = Mockito.mock(Lifecycle::class.java) @@ -3113,23 +3113,23 @@ public class WebAuthProviderTest { delegateCallback = callback, lifecycleOwner = lifecycleOwner, onDetached = { success, detachedError -> - if (success != null) WebAuthProvider.pendingLoginResult.set(WebAuthProvider.PendingResult.Success(success)) - else if (detachedError != null) WebAuthProvider.pendingLoginResult.set(WebAuthProvider.PendingResult.Failure(detachedError)) + capturedSuccess = success + capturedError = detachedError } ) lifecycleCallback.onDestroy(lifecycleOwner) lifecycleCallback.onFailure(error) - val pending = WebAuthProvider.pendingLoginResult.get() - Assert.assertNotNull(pending) - Assert.assertTrue(pending is WebAuthProvider.PendingResult.Failure) + Assert.assertNull(capturedSuccess) + Assert.assertEquals(error, capturedError) verify(callback, Mockito.never()).onFailure(any()) } @Test public fun shouldDeliverDirectlyWhenLifecycleCallbackIsAlive() { val credentials = Mockito.mock(Credentials::class.java) + var onDetachedCalled = false val lifecycleOwner = Mockito.mock(LifecycleOwner::class.java) val lifecycle = Mockito.mock(Lifecycle::class.java) @@ -3138,21 +3138,19 @@ public class WebAuthProviderTest { val lifecycleCallback = LifecycleAwareCallback( delegateCallback = callback, lifecycleOwner = lifecycleOwner, - onDetached = { success, error -> - if (success != null) WebAuthProvider.pendingLoginResult.set(WebAuthProvider.PendingResult.Success(success)) - else if (error != null) WebAuthProvider.pendingLoginResult.set(WebAuthProvider.PendingResult.Failure(error)) - } + onDetached = { _, _ -> onDetachedCalled = true } ) lifecycleCallback.onSuccess(credentials) verify(callback).onSuccess(credentials) - Assert.assertNull(WebAuthProvider.pendingLoginResult.get()) + Assert.assertFalse(onDetachedCalled) } @Test public fun shouldDeliverFailureDirectlyWhenLifecycleCallbackIsAlive() { val error = AuthenticationException("canceled", "User canceled") + var onDetachedCalled = false val lifecycleOwner = Mockito.mock(LifecycleOwner::class.java) val lifecycle = Mockito.mock(Lifecycle::class.java) @@ -3161,21 +3159,19 @@ public class WebAuthProviderTest { val lifecycleCallback = LifecycleAwareCallback( delegateCallback = callback, lifecycleOwner = lifecycleOwner, - onDetached = { success, detachedError -> - if (success != null) WebAuthProvider.pendingLoginResult.set(WebAuthProvider.PendingResult.Success(success)) - else if (detachedError != null) WebAuthProvider.pendingLoginResult.set(WebAuthProvider.PendingResult.Failure(detachedError)) - } + onDetached = { _, _ -> onDetachedCalled = true } ) lifecycleCallback.onFailure(error) verify(callback).onFailure(error) - Assert.assertNull(WebAuthProvider.pendingLoginResult.get()) + Assert.assertFalse(onDetachedCalled) } @Test public fun shouldCacheLogoutSuccessWhenLifecycleCallbackIsDetachedOnDestroy() { - WebAuthProvider.pendingLogoutResult.set(null) + var capturedSuccess = false + var capturedError: AuthenticationException? = null val lifecycleOwner = Mockito.mock(LifecycleOwner::class.java) val lifecycle = Mockito.mock(Lifecycle::class.java) @@ -3184,25 +3180,25 @@ public class WebAuthProviderTest { val lifecycleCallback = LifecycleAwareCallback( delegateCallback = voidCallback, lifecycleOwner = lifecycleOwner, - onDetached = { success, error -> - if (error != null) WebAuthProvider.pendingLogoutResult.set(WebAuthProvider.PendingResult.Failure(error)) - else WebAuthProvider.pendingLogoutResult.set(WebAuthProvider.PendingResult.Success(success)) + onDetached = { _, error -> + capturedError = error + if (error == null) capturedSuccess = true } ) lifecycleCallback.onDestroy(lifecycleOwner) lifecycleCallback.onSuccess(null) - val pending = WebAuthProvider.pendingLogoutResult.get() - Assert.assertNotNull(pending) - Assert.assertTrue(pending is WebAuthProvider.PendingResult.Success) + Assert.assertTrue(capturedSuccess) + Assert.assertNull(capturedError) verify(voidCallback, Mockito.never()).onSuccess(any()) } @Test public fun shouldCacheLogoutFailureWhenLifecycleCallbackIsDetachedOnDestroy() { val error = AuthenticationException("canceled", "User closed the browser") - WebAuthProvider.pendingLogoutResult.set(null) + var capturedSuccess = false + var capturedError: AuthenticationException? = null val lifecycleOwner = Mockito.mock(LifecycleOwner::class.java) val lifecycle = Mockito.mock(Lifecycle::class.java) @@ -3211,23 +3207,24 @@ public class WebAuthProviderTest { val lifecycleCallback = LifecycleAwareCallback( delegateCallback = voidCallback, lifecycleOwner = lifecycleOwner, - onDetached = { success, detachedError -> - if (detachedError != null) WebAuthProvider.pendingLogoutResult.set(WebAuthProvider.PendingResult.Failure(detachedError)) - else WebAuthProvider.pendingLogoutResult.set(WebAuthProvider.PendingResult.Success(success)) + onDetached = { _, detachedError -> + capturedError = detachedError + if (detachedError == null) capturedSuccess = true } ) lifecycleCallback.onDestroy(lifecycleOwner) lifecycleCallback.onFailure(error) - val pending = WebAuthProvider.pendingLogoutResult.get() - Assert.assertNotNull(pending) - Assert.assertTrue(pending is WebAuthProvider.PendingResult.Failure) + Assert.assertFalse(capturedSuccess) + Assert.assertEquals(error, capturedError) verify(voidCallback, Mockito.never()).onFailure(any()) } @Test public fun shouldDeliverLogoutDirectlyWhenLifecycleCallbackIsAlive() { + var onDetachedCalled = false + val lifecycleOwner = Mockito.mock(LifecycleOwner::class.java) val lifecycle = Mockito.mock(Lifecycle::class.java) Mockito.`when`(lifecycleOwner.lifecycle).thenReturn(lifecycle) @@ -3235,16 +3232,13 @@ public class WebAuthProviderTest { val lifecycleCallback = LifecycleAwareCallback( delegateCallback = voidCallback, lifecycleOwner = lifecycleOwner, - onDetached = { success, error -> - if (error != null) WebAuthProvider.pendingLogoutResult.set(WebAuthProvider.PendingResult.Failure(error)) - else WebAuthProvider.pendingLogoutResult.set(WebAuthProvider.PendingResult.Success(success)) - } + onDetached = { _, _ -> onDetachedCalled = true } ) lifecycleCallback.onSuccess(null) verify(voidCallback).onSuccess(null) - Assert.assertNull(WebAuthProvider.pendingLogoutResult.get()) + Assert.assertFalse(onDetachedCalled) } @Test @@ -3282,27 +3276,27 @@ public class WebAuthProviderTest { @Test public fun shouldClearPendingLoginResultOnNewLoginStart() { val staleCredentials = Mockito.mock(Credentials::class.java) - WebAuthProvider.pendingLoginResult.set(WebAuthProvider.PendingResult.Success(staleCredentials)) + setPendingLoginResult(WebAuthProvider.PendingResult.Success(staleCredentials)) login(account).start(activity, callback) - Assert.assertNull(WebAuthProvider.pendingLoginResult.get()) + Assert.assertNull(getPendingLoginResult()) } @Test public fun shouldClearPendingLogoutResultOnNewLogoutStart() { - WebAuthProvider.pendingLogoutResult.set(WebAuthProvider.PendingResult.Success(null)) + setPendingLogoutResult(WebAuthProvider.PendingResult.Success(null)) logout(account).start(activity, voidCallback) - Assert.assertNull(WebAuthProvider.pendingLogoutResult.get()) + Assert.assertNull(getPendingLogoutResult()) } @Test public fun shouldDeliverPendingLoginResultOnResume() { val credentials = Mockito.mock(Credentials::class.java) - WebAuthProvider.pendingLoginResult.set(WebAuthProvider.PendingResult.Success(credentials)) + setPendingLoginResult(WebAuthProvider.PendingResult.Success(credentials)) val lifecycleOwner = Mockito.mock(LifecycleOwner::class.java) val lifecycle = Mockito.mock(Lifecycle::class.java) @@ -3317,13 +3311,13 @@ public class WebAuthProviderTest { observerCaptor.firstValue.onResume(lifecycleOwner) verify(callback).onSuccess(credentials) - Assert.assertNull(WebAuthProvider.pendingLoginResult.get()) + Assert.assertNull(getPendingLoginResult()) } @Test public fun shouldDeliverPendingLoginFailureOnResume() { val error = AuthenticationException("canceled", "User canceled") - WebAuthProvider.pendingLoginResult.set(WebAuthProvider.PendingResult.Failure(error)) + setPendingLoginResult(WebAuthProvider.PendingResult.Failure(error)) val lifecycleOwner = Mockito.mock(LifecycleOwner::class.java) val lifecycle = Mockito.mock(Lifecycle::class.java) @@ -3336,12 +3330,12 @@ public class WebAuthProviderTest { observerCaptor.firstValue.onResume(lifecycleOwner) verify(callback).onFailure(error) - Assert.assertNull(WebAuthProvider.pendingLoginResult.get()) + Assert.assertNull(getPendingLoginResult()) } @Test public fun shouldDeliverPendingLogoutResultOnResume() { - WebAuthProvider.pendingLogoutResult.set(WebAuthProvider.PendingResult.Success(null)) + setPendingLogoutResult(WebAuthProvider.PendingResult.Success(null)) val lifecycleOwner = Mockito.mock(LifecycleOwner::class.java) val lifecycle = Mockito.mock(Lifecycle::class.java) @@ -3354,13 +3348,13 @@ public class WebAuthProviderTest { observerCaptor.firstValue.onResume(lifecycleOwner) verify(voidCallback).onSuccess(null) - Assert.assertNull(WebAuthProvider.pendingLogoutResult.get()) + Assert.assertNull(getPendingLogoutResult()) } @Test public fun shouldDeliverPendingLogoutFailureOnResume() { val error = AuthenticationException("canceled", "User closed the browser") - WebAuthProvider.pendingLogoutResult.set(WebAuthProvider.PendingResult.Failure(error)) + setPendingLogoutResult(WebAuthProvider.PendingResult.Failure(error)) val lifecycleOwner = Mockito.mock(LifecycleOwner::class.java) val lifecycle = Mockito.mock(Lifecycle::class.java) @@ -3373,7 +3367,7 @@ public class WebAuthProviderTest { observerCaptor.firstValue.onResume(lifecycleOwner) verify(voidCallback).onFailure(error) - Assert.assertNull(WebAuthProvider.pendingLogoutResult.get()) + Assert.assertNull(getPendingLogoutResult()) } @Test @@ -3407,7 +3401,7 @@ public class WebAuthProviderTest { @Test public fun shouldNotDeliverLogoutResultWhenNoLogoutCallbackPassedToRegisterCallbacks() { - WebAuthProvider.pendingLogoutResult.set(WebAuthProvider.PendingResult.Success(null)) + setPendingLogoutResult(WebAuthProvider.PendingResult.Success(null)) val lifecycleOwner = Mockito.mock(LifecycleOwner::class.java) val lifecycle = Mockito.mock(Lifecycle::class.java) @@ -3421,7 +3415,36 @@ public class WebAuthProviderTest { observerCaptor.firstValue.onResume(lifecycleOwner) verify(voidCallback, Mockito.never()).onSuccess(any()) - Assert.assertNotNull(WebAuthProvider.pendingLogoutResult.get()) + Assert.assertNotNull(getPendingLogoutResult()) + } + + // Reflection helpers — pendingLoginResult/pendingLogoutResult are private in WebAuthProvider + @Suppress("UNCHECKED_CAST") + private fun setPendingLoginResult(result: WebAuthProvider.PendingResult?) { + val field = WebAuthProvider::class.java.getDeclaredField("pendingLoginResult") + field.isAccessible = true + (field.get(WebAuthProvider) as AtomicReference?>).set(result) + } + + @Suppress("UNCHECKED_CAST") + private fun getPendingLoginResult(): WebAuthProvider.PendingResult? { + val field = WebAuthProvider::class.java.getDeclaredField("pendingLoginResult") + field.isAccessible = true + return (field.get(WebAuthProvider) as AtomicReference?>).get() + } + + @Suppress("UNCHECKED_CAST") + private fun setPendingLogoutResult(result: WebAuthProvider.PendingResult?) { + val field = WebAuthProvider::class.java.getDeclaredField("pendingLogoutResult") + field.isAccessible = true + (field.get(WebAuthProvider) as AtomicReference?>).set(result) + } + + @Suppress("UNCHECKED_CAST") + private fun getPendingLogoutResult(): WebAuthProvider.PendingResult? { + val field = WebAuthProvider::class.java.getDeclaredField("pendingLogoutResult") + field.isAccessible = true + return (field.get(WebAuthProvider) as AtomicReference?>).get() } private companion object { From ec02d077305e45e9d8a3a5cec35fe164743e7125 Mon Sep 17 00:00:00 2001 From: utkrishtS Date: Thu, 2 Apr 2026 16:05:28 +0530 Subject: [PATCH 08/10] fix: cache result in onRestoreInstanceState for process death recovery, add @Volatile to delegateCallback, make login startInternal private --- .../android/provider/LifecycleAwareCallback.kt | 2 +- .../auth0/android/provider/WebAuthProvider.kt | 18 +++++++++++++----- 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/auth0/src/main/java/com/auth0/android/provider/LifecycleAwareCallback.kt b/auth0/src/main/java/com/auth0/android/provider/LifecycleAwareCallback.kt index 52e5024ff..e1081e491 100644 --- a/auth0/src/main/java/com/auth0/android/provider/LifecycleAwareCallback.kt +++ b/auth0/src/main/java/com/auth0/android/provider/LifecycleAwareCallback.kt @@ -19,7 +19,7 @@ import com.auth0.android.callback.Callback * @param onDetached called when a result arrives but the callback is already detached */ internal class LifecycleAwareCallback( - private var delegateCallback: Callback?, + @Volatile private var delegateCallback: Callback?, lifecycleOwner: LifecycleOwner, private val onDetached: (success: S?, error: AuthenticationException?) -> Unit, ) : Callback, DefaultLifecycleObserver { 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 179399933..358175e71 100644 --- a/auth0/src/main/java/com/auth0/android/provider/WebAuthProvider.kt +++ b/auth0/src/main/java/com/auth0/android/provider/WebAuthProvider.kt @@ -204,14 +204,22 @@ public object WebAuthProvider { state, object : Callback { override fun onSuccess(result: Credentials) { - for (callback in callbacks) { - callback.onSuccess(result) + if (callbacks.isNotEmpty()) { + for (callback in callbacks) { + callback.onSuccess(result) + } + } else { + pendingLoginResult.set(PendingResult.Success(result)) } } override fun onFailure(error: AuthenticationException) { - for (callback in callbacks) { - callback.onFailure(error) + if (callbacks.isNotEmpty()) { + for (callback in callbacks) { + callback.onFailure(error) + } + } else { + pendingLoginResult.set(PendingResult.Failure(error)) } } }, @@ -713,7 +721,7 @@ public object WebAuthProvider { startInternal(context, effectiveCallback) } - internal fun startInternal( + private fun startInternal( context: Context, callback: Callback ) { From 08869534b21e5eec297a4b84d42e38e6766cffa4 Mon Sep 17 00:00:00 2001 From: utkrishtS Date: Thu, 2 Apr 2026 16:48:38 +0530 Subject: [PATCH 09/10] fix: prevent rotation from canceling auth, clear stale pending results on new flow start, make pending fields internal for direct test access --- .../provider/AuthenticationActivity.kt | 10 ++++---- .../auth0/android/provider/WebAuthProvider.kt | 6 +++-- .../android/provider/WebAuthProviderTest.kt | 23 ++++--------------- 3 files changed, 15 insertions(+), 24 deletions(-) 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..b2cf97b54 100644 --- a/auth0/src/main/java/com/auth0/android/provider/AuthenticationActivity.kt +++ b/auth0/src/main/java/com/auth0/android/provider/AuthenticationActivity.kt @@ -57,12 +57,14 @@ public open class AuthenticationActivity : Activity() { launchAuthenticationIntent() return } + // Only deliver result if intent.data is present (user returned from browser). + // If data is null, the Activity resumed due to rotation or other config change + // while the CustomTab is still open — don't finish, let the browser continue. val resultMissing = authenticationIntent.data == null - if (resultMissing) { - setResult(RESULT_CANCELED) + if (!resultMissing) { + deliverAuthenticationResult(authenticationIntent) + finish() } - deliverAuthenticationResult(authenticationIntent) - finish() } override fun onDestroy() { 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 358175e71..3a174fbaa 100644 --- a/auth0/src/main/java/com/auth0/android/provider/WebAuthProvider.kt +++ b/auth0/src/main/java/com/auth0/android/provider/WebAuthProvider.kt @@ -50,9 +50,9 @@ public object WebAuthProvider { data class Failure(val error: AuthenticationException) : PendingResult() } - private val pendingLoginResult = AtomicReference?>(null) + internal val pendingLoginResult = AtomicReference?>(null) - private val pendingLogoutResult = AtomicReference?>(null) + internal val pendingLogoutResult = AtomicReference?>(null) /** * Registers login (and optionally logout) callbacks for the duration of the given @@ -332,6 +332,7 @@ public object WebAuthProvider { */ public fun start(context: Context, callback: Callback) { pendingLogoutResult.set(null) + pendingLoginResult.set(null) val effectiveCallback = if (context is LifecycleOwner) { LifecycleAwareCallback( @@ -703,6 +704,7 @@ public object WebAuthProvider { callback: Callback ) { pendingLoginResult.set(null) + pendingLogoutResult.set(null) val effectiveCallback = if (context is LifecycleOwner) { LifecycleAwareCallback( delegateCallback = callback, 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 5a302f39c..320330ca7 100644 --- a/auth0/src/test/java/com/auth0/android/provider/WebAuthProviderTest.kt +++ b/auth0/src/test/java/com/auth0/android/provider/WebAuthProviderTest.kt @@ -65,7 +65,6 @@ import java.io.InputStream import java.nio.file.Files import java.nio.file.Paths import java.util.* -import java.util.concurrent.atomic.AtomicReference @RunWith(RobolectricTestRunner::class) @Config(shadows = [ThreadSwitcherShadow::class]) @@ -3418,33 +3417,21 @@ public class WebAuthProviderTest { Assert.assertNotNull(getPendingLogoutResult()) } - // Reflection helpers — pendingLoginResult/pendingLogoutResult are private in WebAuthProvider - @Suppress("UNCHECKED_CAST") + // Direct access — pendingLoginResult/pendingLogoutResult are internal in WebAuthProvider private fun setPendingLoginResult(result: WebAuthProvider.PendingResult?) { - val field = WebAuthProvider::class.java.getDeclaredField("pendingLoginResult") - field.isAccessible = true - (field.get(WebAuthProvider) as AtomicReference?>).set(result) + WebAuthProvider.pendingLoginResult.set(result) } - @Suppress("UNCHECKED_CAST") private fun getPendingLoginResult(): WebAuthProvider.PendingResult? { - val field = WebAuthProvider::class.java.getDeclaredField("pendingLoginResult") - field.isAccessible = true - return (field.get(WebAuthProvider) as AtomicReference?>).get() + return WebAuthProvider.pendingLoginResult.get() } - @Suppress("UNCHECKED_CAST") private fun setPendingLogoutResult(result: WebAuthProvider.PendingResult?) { - val field = WebAuthProvider::class.java.getDeclaredField("pendingLogoutResult") - field.isAccessible = true - (field.get(WebAuthProvider) as AtomicReference?>).set(result) + WebAuthProvider.pendingLogoutResult.set(result) } - @Suppress("UNCHECKED_CAST") private fun getPendingLogoutResult(): WebAuthProvider.PendingResult? { - val field = WebAuthProvider::class.java.getDeclaredField("pendingLogoutResult") - field.isAccessible = true - return (field.get(WebAuthProvider) as AtomicReference?>).get() + return WebAuthProvider.pendingLogoutResult.get() } private companion object { From c84ed7abe6eaa30be9f6652bcdcdbc065dbf5f38 Mon Sep 17 00:00:00 2001 From: utkrishtS Date: Thu, 2 Apr 2026 17:15:00 +0530 Subject: [PATCH 10/10] Fixing UT case --- .../android/provider/AuthenticationActivityTest.kt | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) 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 ace70dd8f..8e86aebbe 100644 --- a/auth0/src/test/java/com/auth0/android/provider/AuthenticationActivityTest.kt +++ b/auth0/src/test/java/com/auth0/android/provider/AuthenticationActivityTest.kt @@ -214,14 +214,10 @@ public class AuthenticationActivityTest { MatcherAssert.assertThat(launchAsTwaCaptor.value, Is.`is`(false)) MatcherAssert.assertThat(activity.deliveredIntent, Is.`is`(Matchers.nullValue())) activityController.pause().stop() - //Browser is shown + //Browser is shown, resume WITHOUT new intent — should NOT deliver or finish activityController.start().resume() - MatcherAssert.assertThat(activity.deliveredIntent, Is.`is`(Matchers.notNullValue())) - MatcherAssert.assertThat( - activity.deliveredIntent!!.data, - Is.`is`(Matchers.nullValue()) - ) //null data == canceled - MatcherAssert.assertThat(activity.isFinishing, Is.`is`(true)) + MatcherAssert.assertThat(activity.deliveredIntent, Is.`is`(Matchers.nullValue())) //nothing delivered + MatcherAssert.assertThat(activity.isFinishing, Is.`is`(false)) //still waiting for result activityController.destroy() Mockito.verify(customTabsController).unbindService() }