From b6cacb38cd0c728e152bacde352a1e9728da7820 Mon Sep 17 00:00:00 2001 From: utkrishtS Date: Tue, 24 Mar 2026 19:29:58 +0530 Subject: [PATCH] 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 2b569026..3c05cdf2 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 ea4fe01e..9839d668 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 824c7371..2b515c3e 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 b09366e6..12ea9a54 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 70cf647f..1b138362 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 e8bebf33..25e5747a 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"