Skip to content

Commit 8058fe8

Browse files
committed
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
1 parent b6cacb3 commit 8058fe8

File tree

5 files changed

+361
-103
lines changed

5 files changed

+361
-103
lines changed
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
package com.auth0.android.provider
2+
3+
import androidx.lifecycle.DefaultLifecycleObserver
4+
import androidx.lifecycle.LifecycleOwner
5+
import com.auth0.android.authentication.AuthenticationException
6+
import com.auth0.android.callback.Callback
7+
8+
/**
9+
* Wraps a user-provided callback and observes the Activity/Fragment lifecycle.
10+
* When the host is destroyed (e.g. config change), [inner] is set to null so
11+
* the destroyed Activity is no longer referenced by the SDK.
12+
*
13+
* If a result arrives after [inner] has been cleared, the [onDetached] lambda
14+
* is invoked to cache the result for later recovery via consumePending*Result().
15+
*
16+
* @param S the success type (Credentials for login, Void? for logout)
17+
* @param inner the user's original callback
18+
* @param lifecycleOwner the Activity or Fragment whose lifecycle to observe
19+
* @param onDetached called when a result arrives but the callback is already detached
20+
*/
21+
internal class LifecycleAwareCallback<S>(
22+
@Volatile private var inner: Callback<S, AuthenticationException>?,
23+
lifecycleOwner: LifecycleOwner,
24+
private val onDetached: (success: S?, error: AuthenticationException?) -> Unit,
25+
) : Callback<S, AuthenticationException>, DefaultLifecycleObserver {
26+
27+
init {
28+
lifecycleOwner.lifecycle.addObserver(this)
29+
}
30+
31+
override fun onSuccess(result: S) {
32+
val cb = inner
33+
if (cb != null) {
34+
cb.onSuccess(result)
35+
} else {
36+
onDetached(result, null)
37+
}
38+
}
39+
40+
override fun onFailure(error: AuthenticationException) {
41+
val cb = inner
42+
if (cb != null) {
43+
cb.onFailure(error)
44+
} else {
45+
onDetached(null, error)
46+
}
47+
}
48+
49+
override fun onDestroy(owner: LifecycleOwner) {
50+
inner = null
51+
owner.lifecycle.removeObserver(this)
52+
}
53+
}

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

Lines changed: 4 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -6,39 +6,17 @@ 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
109
import java.util.*
1110

1211
internal class LogoutManager(
1312
private val account: Auth0,
14-
callback: Callback<Void?, AuthenticationException>,
13+
private val callback: Callback<Void?, AuthenticationException>,
1514
returnToUrl: String,
1615
ctOptions: CustomTabsOptions,
1716
federated: Boolean = false,
1817
private val launchAsTwa: Boolean = false,
1918
private val customLogoutUrl: String? = null
2019
) : 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-
}
4220
private val parameters: MutableMap<String, String>
4321
private val ctOptions: CustomTabsOptions
4422
fun startLogout(context: Context) {
@@ -53,15 +31,15 @@ internal class LogoutManager(
5331
AuthenticationException.ERROR_VALUE_AUTHENTICATION_CANCELED,
5432
"The user closed the browser app so the logout was cancelled."
5533
)
56-
deliverFailure(exception)
34+
callback.onFailure(exception)
5735
} else {
58-
deliverSuccess()
36+
callback.onSuccess(null)
5937
}
6038
return true
6139
}
6240

6341
override fun failure(exception: AuthenticationException) {
64-
deliverFailure(exception)
42+
callback.onFailure(exception)
6543
}
6644

6745
private fun buildLogoutUri(): Uri {

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

Lines changed: 8 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -16,41 +16,19 @@ 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
2019
import java.security.SecureRandom
2120
import java.util.*
2221

2322
internal class OAuthManager(
2423
private val account: Auth0,
25-
callback: Callback<Credentials, AuthenticationException>,
24+
private val callback: Callback<Credentials, AuthenticationException>,
2625
parameters: Map<String, String>,
2726
ctOptions: CustomTabsOptions,
2827
private val launchAsTwa: Boolean = false,
2928
private val customAuthorizeUrl: String? = null,
3029
@get:VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
3130
internal val dPoP: DPoP? = null
3231
) : 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-
}
5432

5533
private val parameters: MutableMap<String, String>
5634
private val headers: MutableMap<String, String>
@@ -91,7 +69,7 @@ internal class OAuthManager(
9169
try {
9270
addDPoPJWKParameters(parameters)
9371
} catch (ex: DPoPException) {
94-
deliverFailure(
72+
callback.onFailure(
9573
AuthenticationException(
9674
ex.message ?: "Error generating the JWK",
9775
ex
@@ -120,7 +98,7 @@ internal class OAuthManager(
12098
AuthenticationException.ERROR_VALUE_AUTHENTICATION_CANCELED,
12199
"The user closed the browser app and the authentication was canceled."
122100
)
123-
deliverFailure(exception)
101+
callback.onFailure(exception)
124102
return true
125103
}
126104
val values = CallbackHelper.getValuesFromUri(result.intentData)
@@ -133,7 +111,7 @@ internal class OAuthManager(
133111
assertNoError(values[KEY_ERROR], values[KEY_ERROR_DESCRIPTION])
134112
assertValidState(parameters[KEY_STATE]!!, values[KEY_STATE])
135113
} catch (e: AuthenticationException) {
136-
deliverFailure(e)
114+
callback.onFailure(e)
137115
return true
138116
}
139117

@@ -146,14 +124,14 @@ internal class OAuthManager(
146124
credentials.idToken,
147125
object : Callback<Void?, Auth0Exception> {
148126
override fun onSuccess(result: Void?) {
149-
deliverSuccess(credentials)
127+
callback.onSuccess(credentials)
150128
}
151129

152130
override fun onFailure(error: Auth0Exception) {
153131
val wrappedError = AuthenticationException(
154132
ERROR_VALUE_ID_TOKEN_VALIDATION_FAILED, error
155133
)
156-
deliverFailure(wrappedError)
134+
callback.onFailure(wrappedError)
157135
}
158136
})
159137
}
@@ -165,14 +143,14 @@ internal class OAuthManager(
165143
"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'."
166144
)
167145
}
168-
deliverFailure(error)
146+
callback.onFailure(error)
169147
}
170148
})
171149
return true
172150
}
173151

174152
public override fun failure(exception: AuthenticationException) {
175-
deliverFailure(exception)
153+
callback.onFailure(exception)
176154
}
177155

178156
private fun assertValidIdToken(

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

Lines changed: 58 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import android.net.Uri
66
import android.os.Bundle
77
import android.util.Log
88
import androidx.annotation.VisibleForTesting
9+
import androidx.lifecycle.LifecycleOwner
910
import com.auth0.android.Auth0
1011
import com.auth0.android.annotation.ExperimentalAuth0Api
1112
import com.auth0.android.authentication.AuthenticationException
@@ -18,6 +19,7 @@ import kotlinx.coroutines.suspendCancellableCoroutine
1819
import kotlinx.coroutines.withContext
1920
import java.util.Locale
2021
import java.util.concurrent.CopyOnWriteArraySet
22+
import java.util.concurrent.atomic.AtomicReference
2123
import kotlin.coroutines.CoroutineContext
2224
import kotlin.coroutines.resume
2325
import kotlin.coroutines.resumeWithException
@@ -49,15 +51,13 @@ public object WebAuthProvider {
4951
data class Failure<E>(val error: E) : PendingResult<Nothing, E>()
5052
}
5153

52-
@Volatile
53-
@JvmStatic
54-
@get:VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
55-
internal var pendingLoginResult: PendingResult<Credentials, AuthenticationException>? = null
54+
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
55+
internal val pendingLoginResult =
56+
AtomicReference<PendingResult<Credentials, AuthenticationException>?>(null)
5657

57-
@Volatile
58-
@JvmStatic
59-
@get:VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
60-
internal var pendingLogoutResult: PendingResult<Void?, AuthenticationException>? = null
58+
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
59+
internal val pendingLogoutResult =
60+
AtomicReference<PendingResult<Void?, AuthenticationException>?>(null)
6161

6262
/**
6363
* Check for and consume a pending login result that arrived during a configuration change.
@@ -69,8 +69,7 @@ public object WebAuthProvider {
6969
*/
7070
@JvmStatic
7171
public fun consumePendingLoginResult(callback: Callback<Credentials, AuthenticationException>): Boolean {
72-
val result = pendingLoginResult ?: return false
73-
pendingLoginResult = null
72+
val result = pendingLoginResult.getAndSet(null) ?: return false
7473
when (result) {
7574
is PendingResult.Success -> callback.onSuccess(result.result)
7675
is PendingResult.Failure -> callback.onFailure(result.error)
@@ -89,8 +88,7 @@ public object WebAuthProvider {
8988
*/
9089
@JvmStatic
9190
public fun consumePendingLogoutResult(callback: Callback<Void?, AuthenticationException>): Boolean {
92-
val result = pendingLogoutResult ?: return false
93-
pendingLogoutResult = null
91+
val result = pendingLogoutResult.getAndSet(null) ?: return false
9492
when (result) {
9593
is PendingResult.Success -> callback.onSuccess(result.result)
9694
is PendingResult.Failure -> callback.onFailure(result.error)
@@ -302,6 +300,27 @@ public object WebAuthProvider {
302300
* @see AuthenticationException.isAuthenticationCanceled
303301
*/
304302
public fun start(context: Context, callback: Callback<Void?, AuthenticationException>) {
303+
pendingLogoutResult.set(null)
304+
305+
val effectiveCallback = if (context is LifecycleOwner) {
306+
LifecycleAwareCallback<Void?>(
307+
inner = callback,
308+
lifecycleOwner = context as LifecycleOwner,
309+
onDetached = { _: Void?, error: AuthenticationException? ->
310+
if (error != null) {
311+
pendingLogoutResult.set(PendingResult.Failure(error))
312+
} else {
313+
pendingLogoutResult.set(PendingResult.Success(null))
314+
}
315+
}
316+
)
317+
} else {
318+
callback
319+
}
320+
startInternal(context, effectiveCallback)
321+
}
322+
323+
internal fun startInternal(context: Context, callback: Callback<Void?, AuthenticationException>) {
305324
resetManagerInstance()
306325
if (!ctOptions.hasCompatibleBrowser(context.packageManager)) {
307326
val ex = AuthenticationException(
@@ -346,7 +365,7 @@ public object WebAuthProvider {
346365
) {
347366
return withContext(coroutineContext) {
348367
suspendCancellableCoroutine { continuation ->
349-
start(context, object : Callback<Void?, AuthenticationException> {
368+
startInternal(context, object : Callback<Void?, AuthenticationException> {
350369
override fun onSuccess(result: Void?) {
351370
continuation.resume(Unit)
352371
}
@@ -652,6 +671,29 @@ public object WebAuthProvider {
652671
public fun start(
653672
context: Context,
654673
callback: Callback<Credentials, AuthenticationException>
674+
) {
675+
pendingLoginResult.set(null)
676+
val effectiveCallback = if (context is LifecycleOwner) {
677+
LifecycleAwareCallback<Credentials>(
678+
inner = callback,
679+
lifecycleOwner = context as LifecycleOwner,
680+
onDetached = { success: Credentials?, error: AuthenticationException? ->
681+
if (success != null) {
682+
pendingLoginResult.set(PendingResult.Success(success))
683+
} else if (error != null) {
684+
pendingLoginResult.set(PendingResult.Failure(error))
685+
}
686+
}
687+
)
688+
} else {
689+
callback
690+
}
691+
startInternal(context, effectiveCallback)
692+
}
693+
694+
internal fun startInternal(
695+
context: Context,
696+
callback: Callback<Credentials, AuthenticationException>
655697
) {
656698
resetManagerInstance()
657699
if (!ctOptions.hasCompatibleBrowser(context.packageManager)) {
@@ -725,7 +767,9 @@ public object WebAuthProvider {
725767
): Credentials {
726768
return withContext(coroutineContext) {
727769
suspendCancellableCoroutine { continuation ->
728-
start(context, object : Callback<Credentials, AuthenticationException> {
770+
// Use startInternal directly — the anonymous callback captures only the
771+
// coroutine continuation, not an Activity, so lifecycle wrapping is not needed
772+
startInternal(context, object : Callback<Credentials, AuthenticationException> {
729773
override fun onSuccess(result: Credentials) {
730774
continuation.resume(result)
731775
}

0 commit comments

Comments
 (0)