Skip to content

Commit b6cacb3

Browse files
committed
fix: handle configuration changes during WebAuth flow to prevent memory leak
1 parent 962addf commit b6cacb3

File tree

6 files changed

+316
-13
lines changed

6 files changed

+316
-13
lines changed

EXAMPLES.md

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -329,9 +329,58 @@ WebAuthProvider.logout(account)
329329

330330
})
331331
```
332-
> [!NOTE]
332+
> [!NOTE]
333333
> 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.
334334
335+
## Handling Configuration Changes During Authentication
336+
337+
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.
338+
339+
```kotlin
340+
class LoginActivity : AppCompatActivity() {
341+
342+
private val loginCallback = object : Callback<Credentials, AuthenticationException> {
343+
override fun onSuccess(result: Credentials) {
344+
// Handle successful login
345+
}
346+
override fun onFailure(error: AuthenticationException) {
347+
// Handle error
348+
}
349+
}
350+
351+
private val logoutCallback = object : Callback<Void?, AuthenticationException> {
352+
override fun onSuccess(result: Void?) {
353+
// Handle successful logout
354+
}
355+
override fun onFailure(error: AuthenticationException) {
356+
// Handle error
357+
}
358+
}
359+
360+
override fun onResume() {
361+
super.onResume()
362+
// Recover any result that arrived while the Activity was being recreated
363+
WebAuthProvider.consumePendingLoginResult(loginCallback)
364+
WebAuthProvider.consumePendingLogoutResult(logoutCallback)
365+
}
366+
367+
fun onLoginClick() {
368+
WebAuthProvider.login(account)
369+
.withScheme("demo")
370+
.start(this, loginCallback)
371+
}
372+
373+
fun onLogoutClick() {
374+
WebAuthProvider.logout(account)
375+
.withScheme("demo")
376+
.start(this, logoutCallback)
377+
}
378+
}
379+
```
380+
381+
> [!NOTE]
382+
> 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.
383+
335384
## Authentication API
336385

337386
The client provides methods to authenticate the user against the Auth0 server.

V4_MIGRATION_GUIDE.md

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ v4 of the Auth0 Android SDK includes significant build toolchain updates, update
2424
- [**Dependency Changes**](#dependency-changes)
2525
+ [Gson 2.8.9 → 2.11.0](#️-gson-289--2110-transitive-dependency)
2626
+ [DefaultClient.Builder](#defaultclientbuilder)
27+
- [**New APIs**](#new-apis)
28+
+ [Handling Configuration Changes During Authentication](#handling-configuration-changes-during-authentication)
2729

2830
---
2931

@@ -283,6 +285,44 @@ The legacy constructor is deprecated but **not removed** — existing code will
283285
and run. Your IDE will show a deprecation warning with a suggested `ReplaceWith` quick-fix to
284286
migrate to the Builder.
285287

288+
## New APIs
289+
290+
### Handling Configuration Changes During Authentication
291+
292+
v4 fixes a memory leak and lost callback issue when the Activity is destroyed during authentication
293+
(e.g. device rotation, locale change, dark mode toggle). The SDK now uses `WeakReference` for
294+
callbacks, so destroyed Activities are properly garbage collected.
295+
296+
If the authentication result arrives while the Activity is being recreated, it is cached internally.
297+
Use `consumePendingLoginResult()` or `consumePendingLogoutResult()` in your `onResume()` to recover it:
298+
299+
```kotlin
300+
class LoginActivity : AppCompatActivity() {
301+
private val callback = object : Callback<Credentials, AuthenticationException> {
302+
override fun onSuccess(result: Credentials) { /* handle credentials */ }
303+
override fun onFailure(error: AuthenticationException) { /* handle error */ }
304+
}
305+
306+
override fun onResume() {
307+
super.onResume()
308+
// Recover result that arrived during configuration change
309+
WebAuthProvider.consumePendingLoginResult(callback)
310+
}
311+
312+
fun onLoginClick() {
313+
WebAuthProvider.login(account)
314+
.withScheme("myapp")
315+
.start(this, callback)
316+
}
317+
}
318+
```
319+
320+
For logout flows, use `WebAuthProvider.consumePendingLogoutResult(callback)` in the same way.
321+
322+
> **Note:** If you use the `suspend fun await()` API from a ViewModel coroutine scope, the
323+
> Activity is never captured in the callback chain, so you do not need `consumePending*` calls.
324+
> See the sample app for a ViewModel-based example.
325+
286326
## Getting Help
287327

288328
If you encounter issues during migration:

auth0/src/main/java/com/auth0/android/provider/LogoutManager.kt

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,17 +6,39 @@ import android.util.Log
66
import com.auth0.android.Auth0
77
import com.auth0.android.authentication.AuthenticationException
88
import com.auth0.android.callback.Callback
9+
import java.lang.ref.WeakReference
910
import java.util.*
1011

1112
internal class LogoutManager(
1213
private val account: Auth0,
13-
private val callback: Callback<Void?, AuthenticationException>,
14+
callback: Callback<Void?, AuthenticationException>,
1415
returnToUrl: String,
1516
ctOptions: CustomTabsOptions,
1617
federated: Boolean = false,
1718
private val launchAsTwa: Boolean = false,
1819
private val customLogoutUrl: String? = null
1920
) : ResumableManager() {
21+
private val callbackRef = WeakReference(callback)
22+
23+
private fun deliverSuccess() {
24+
val cb = callbackRef.get()
25+
if (cb != null) {
26+
cb.onSuccess(null)
27+
} else {
28+
WebAuthProvider.pendingLogoutResult =
29+
WebAuthProvider.PendingResult.Success(null)
30+
}
31+
}
32+
33+
private fun deliverFailure(error: AuthenticationException) {
34+
val cb = callbackRef.get()
35+
if (cb != null) {
36+
cb.onFailure(error)
37+
} else {
38+
WebAuthProvider.pendingLogoutResult =
39+
WebAuthProvider.PendingResult.Failure(error)
40+
}
41+
}
2042
private val parameters: MutableMap<String, String>
2143
private val ctOptions: CustomTabsOptions
2244
fun startLogout(context: Context) {
@@ -31,15 +53,15 @@ internal class LogoutManager(
3153
AuthenticationException.ERROR_VALUE_AUTHENTICATION_CANCELED,
3254
"The user closed the browser app so the logout was cancelled."
3355
)
34-
callback.onFailure(exception)
56+
deliverFailure(exception)
3557
} else {
36-
callback.onSuccess(null)
58+
deliverSuccess()
3759
}
3860
return true
3961
}
4062

4163
override fun failure(exception: AuthenticationException) {
42-
callback.onFailure(exception)
64+
deliverFailure(exception)
4365
}
4466

4567
private fun buildLogoutUri(): Uri {

auth0/src/main/java/com/auth0/android/provider/OAuthManager.kt

Lines changed: 31 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -16,19 +16,42 @@ import com.auth0.android.dpop.DPoPException
1616
import com.auth0.android.request.internal.Jwt
1717
import com.auth0.android.request.internal.OidcUtils
1818
import com.auth0.android.result.Credentials
19+
import java.lang.ref.WeakReference
1920
import java.security.SecureRandom
2021
import java.util.*
2122

2223
internal class OAuthManager(
2324
private val account: Auth0,
24-
private val callback: Callback<Credentials, AuthenticationException>,
25+
callback: Callback<Credentials, AuthenticationException>,
2526
parameters: Map<String, String>,
2627
ctOptions: CustomTabsOptions,
2728
private val launchAsTwa: Boolean = false,
2829
private val customAuthorizeUrl: String? = null,
2930
@get:VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
3031
internal val dPoP: DPoP? = null
3132
) : ResumableManager() {
33+
private val callbackRef = WeakReference(callback)
34+
35+
private fun deliverSuccess(credentials: Credentials) {
36+
val cb = callbackRef.get()
37+
if (cb != null) {
38+
cb.onSuccess(credentials)
39+
} else {
40+
WebAuthProvider.pendingLoginResult =
41+
WebAuthProvider.PendingResult.Success(credentials)
42+
}
43+
}
44+
45+
private fun deliverFailure(error: AuthenticationException) {
46+
val cb = callbackRef.get()
47+
if (cb != null) {
48+
cb.onFailure(error)
49+
} else {
50+
WebAuthProvider.pendingLoginResult =
51+
WebAuthProvider.PendingResult.Failure(error)
52+
}
53+
}
54+
3255
private val parameters: MutableMap<String, String>
3356
private val headers: MutableMap<String, String>
3457
private val ctOptions: CustomTabsOptions
@@ -68,7 +91,7 @@ internal class OAuthManager(
6891
try {
6992
addDPoPJWKParameters(parameters)
7093
} catch (ex: DPoPException) {
71-
callback.onFailure(
94+
deliverFailure(
7295
AuthenticationException(
7396
ex.message ?: "Error generating the JWK",
7497
ex
@@ -97,7 +120,7 @@ internal class OAuthManager(
97120
AuthenticationException.ERROR_VALUE_AUTHENTICATION_CANCELED,
98121
"The user closed the browser app and the authentication was canceled."
99122
)
100-
callback.onFailure(exception)
123+
deliverFailure(exception)
101124
return true
102125
}
103126
val values = CallbackHelper.getValuesFromUri(result.intentData)
@@ -110,7 +133,7 @@ internal class OAuthManager(
110133
assertNoError(values[KEY_ERROR], values[KEY_ERROR_DESCRIPTION])
111134
assertValidState(parameters[KEY_STATE]!!, values[KEY_STATE])
112135
} catch (e: AuthenticationException) {
113-
callback.onFailure(e)
136+
deliverFailure(e)
114137
return true
115138
}
116139

@@ -123,14 +146,14 @@ internal class OAuthManager(
123146
credentials.idToken,
124147
object : Callback<Void?, Auth0Exception> {
125148
override fun onSuccess(result: Void?) {
126-
callback.onSuccess(credentials)
149+
deliverSuccess(credentials)
127150
}
128151

129152
override fun onFailure(error: Auth0Exception) {
130153
val wrappedError = AuthenticationException(
131154
ERROR_VALUE_ID_TOKEN_VALIDATION_FAILED, error
132155
)
133-
callback.onFailure(wrappedError)
156+
deliverFailure(wrappedError)
134157
}
135158
})
136159
}
@@ -142,14 +165,14 @@ internal class OAuthManager(
142165
"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'."
143166
)
144167
}
145-
callback.onFailure(error)
168+
deliverFailure(error)
146169
}
147170
})
148171
return true
149172
}
150173

151174
public override fun failure(exception: AuthenticationException) {
152-
callback.onFailure(exception)
175+
deliverFailure(exception)
153176
}
154177

155178
private fun assertValidIdToken(

auth0/src/main/java/com/auth0/android/provider/WebAuthProvider.kt

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,66 @@ public object WebAuthProvider {
3939
internal var managerInstance: ResumableManager? = null
4040
private set
4141

42+
/**
43+
* Represents a pending authentication or logout result that arrived while
44+
* the original callback was no longer reachable (e.g. Activity destroyed
45+
* during a configuration change).
46+
*/
47+
internal sealed class PendingResult<out S, out E> {
48+
data class Success<S>(val result: S) : PendingResult<S, Nothing>()
49+
data class Failure<E>(val error: E) : PendingResult<Nothing, E>()
50+
}
51+
52+
@Volatile
53+
@JvmStatic
54+
@get:VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
55+
internal var pendingLoginResult: PendingResult<Credentials, AuthenticationException>? = null
56+
57+
@Volatile
58+
@JvmStatic
59+
@get:VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
60+
internal var pendingLogoutResult: PendingResult<Void?, AuthenticationException>? = null
61+
62+
/**
63+
* Check for and consume a pending login result that arrived during a configuration change.
64+
* Call this in your Activity's `onResume()` to recover results that were delivered while the
65+
* Activity was being recreated (e.g. due to screen rotation).
66+
*
67+
* @param callback the callback to deliver the pending result to
68+
* @return true if a pending result was found and delivered, false otherwise
69+
*/
70+
@JvmStatic
71+
public fun consumePendingLoginResult(callback: Callback<Credentials, AuthenticationException>): Boolean {
72+
val result = pendingLoginResult ?: return false
73+
pendingLoginResult = null
74+
when (result) {
75+
is PendingResult.Success -> callback.onSuccess(result.result)
76+
is PendingResult.Failure -> callback.onFailure(result.error)
77+
}
78+
resetManagerInstance()
79+
return true
80+
}
81+
82+
/**
83+
* Check for and consume a pending logout result that arrived during a configuration change.
84+
* Call this in your Activity's `onResume()` to recover results that were delivered while the
85+
* Activity was being recreated (e.g. due to screen rotation).
86+
*
87+
* @param callback the callback to deliver the pending result to
88+
* @return true if a pending result was found and delivered, false otherwise
89+
*/
90+
@JvmStatic
91+
public fun consumePendingLogoutResult(callback: Callback<Void?, AuthenticationException>): Boolean {
92+
val result = pendingLogoutResult ?: return false
93+
pendingLogoutResult = null
94+
when (result) {
95+
is PendingResult.Success -> callback.onSuccess(result.result)
96+
is PendingResult.Failure -> callback.onFailure(result.error)
97+
}
98+
resetManagerInstance()
99+
return true
100+
}
101+
42102
@JvmStatic
43103
public fun addCallback(callback: Callback<Credentials, AuthenticationException>) {
44104
callbacks += callback

0 commit comments

Comments
 (0)