From cc9983e31034097ce4080070f9bbff9f0777b180 Mon Sep 17 00:00:00 2001 From: Prince Mathew Date: Mon, 8 Jun 2026 12:14:15 +0530 Subject: [PATCH 01/11] fix : Handling DPoP enabled WebAuth flow after process death --- .../provider/AuthenticationActivity.kt | 2 +- .../auth0/android/provider/OAuthManager.kt | 15 +++- .../android/provider/OAuthManagerState.kt | 9 +- .../auth0/android/provider/WebAuthProvider.kt | 5 +- .../android/provider/OAuthManagerStateTest.kt | 84 +++++++++++++++++++ .../android/provider/WebAuthProviderTest.kt | 22 +++++ 6 files changed, 126 insertions(+), 11 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 5f3c3f4e6..b0f413cc6 100644 --- a/auth0/src/main/java/com/auth0/android/provider/AuthenticationActivity.kt +++ b/auth0/src/main/java/com/auth0/android/provider/AuthenticationActivity.kt @@ -40,7 +40,7 @@ public open class AuthenticationActivity : Activity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) if (savedInstanceState != null) { - WebAuthProvider.onRestoreInstanceState(savedInstanceState) + WebAuthProvider.onRestoreInstanceState(savedInstanceState, this) intentLaunched = savedInstanceState.getBoolean(EXTRA_INTENT_LAUNCHED, false) } } 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 acc2b2864..718d3a788 100644 --- a/auth0/src/main/java/com/auth0/android/provider/OAuthManager.kt +++ b/auth0/src/main/java/com/auth0/android/provider/OAuthManager.kt @@ -29,6 +29,7 @@ internal class OAuthManager( @get:VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) internal val dPoP: DPoP? = null ) : ResumableManager() { + private val parameters: MutableMap private val headers: MutableMap private val ctOptions: CustomTabsOptions @@ -211,7 +212,8 @@ internal class OAuthManager( auth0 = account, idTokenVerificationIssuer = idTokenVerificationIssuer, idTokenVerificationLeeway = idTokenVerificationLeeway, - customAuthorizeUrl = this.customAuthorizeUrl + customAuthorizeUrl = this.customAuthorizeUrl, + dPoPEnabled = dPoP != null ) } @@ -387,14 +389,21 @@ internal class OAuthManager( internal fun OAuthManager.Companion.fromState( state: OAuthManagerState, - callback: Callback + callback: Callback, + context: Context ): OAuthManager { + // Enable DPoP on the restored PKCE's AuthenticationAPIClient so that + // the token exchange request includes the DPoP proof after process restore. + if (state.dPoPEnabled && state.pkce != null) { + state.pkce.apiClient.useDPoP(context) + } return OAuthManager( account = state.auth0, ctOptions = state.ctOptions, parameters = state.parameters, callback = callback, - customAuthorizeUrl = state.customAuthorizeUrl + customAuthorizeUrl = state.customAuthorizeUrl, + dPoP = if (state.dPoPEnabled ) DPoP(context) else null ).apply { setHeaders( state.headers diff --git a/auth0/src/main/java/com/auth0/android/provider/OAuthManagerState.kt b/auth0/src/main/java/com/auth0/android/provider/OAuthManagerState.kt index ab677af6c..06d0c6b0b 100644 --- a/auth0/src/main/java/com/auth0/android/provider/OAuthManagerState.kt +++ b/auth0/src/main/java/com/auth0/android/provider/OAuthManagerState.kt @@ -6,7 +6,6 @@ import android.util.Base64 import androidx.core.os.ParcelCompat import com.auth0.android.Auth0 import com.auth0.android.authentication.AuthenticationAPIClient -import com.auth0.android.dpop.DPoP import com.auth0.android.request.internal.GsonProvider import com.google.gson.Gson @@ -20,7 +19,7 @@ internal data class OAuthManagerState( val idTokenVerificationLeeway: Int?, val idTokenVerificationIssuer: String?, val customAuthorizeUrl: String? = null, - val dPoP: DPoP? = null + val dPoPEnabled: Boolean = false ) { private class OAuthManagerJson( @@ -37,7 +36,7 @@ internal data class OAuthManagerState( val idTokenVerificationLeeway: Int?, val idTokenVerificationIssuer: String?, val customAuthorizeUrl: String? = null, - val dPoP: DPoP? = null + val dPoPEnabled: Boolean ) fun serializeToJson( @@ -62,7 +61,7 @@ internal data class OAuthManagerState( idTokenVerificationIssuer = idTokenVerificationIssuer, idTokenVerificationLeeway = idTokenVerificationLeeway, customAuthorizeUrl = this.customAuthorizeUrl, - dPoP = this.dPoP + dPoPEnabled = this.dPoPEnabled ) return gson.toJson(json) } finally { @@ -112,7 +111,7 @@ internal data class OAuthManagerState( idTokenVerificationIssuer = oauthManagerJson.idTokenVerificationIssuer, idTokenVerificationLeeway = oauthManagerJson.idTokenVerificationLeeway, customAuthorizeUrl = oauthManagerJson.customAuthorizeUrl, - dPoP = oauthManagerJson.dPoP + dPoPEnabled = oauthManagerJson.dPoPEnabled ) } finally { parcel.recycle() 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 6d647a432..a668e0704 100644 --- a/auth0/src/main/java/com/auth0/android/provider/WebAuthProvider.kt +++ b/auth0/src/main/java/com/auth0/android/provider/WebAuthProvider.kt @@ -142,7 +142,7 @@ public object WebAuthProvider : SenderConstraining { } } - internal fun onRestoreInstanceState(bundle: Bundle) { + internal fun onRestoreInstanceState(bundle: Bundle, context: Context) { if (managerInstance == null) { val oauthStateJson = bundle.getString(KEY_BUNDLE_OAUTH_MANAGER_STATE).orEmpty() val parStateJson = bundle.getString(KEY_BUNDLE_PAR_MANAGER_STATE).orEmpty() @@ -162,7 +162,8 @@ public object WebAuthProvider : SenderConstraining { callback.onFailure(error) } } - } + }, + context ) } else if (parStateJson.isNotBlank()) { val state = PARCodeManagerState.deserializeState(parStateJson) diff --git a/auth0/src/test/java/com/auth0/android/provider/OAuthManagerStateTest.kt b/auth0/src/test/java/com/auth0/android/provider/OAuthManagerStateTest.kt index e4ac82383..0543e0a47 100644 --- a/auth0/src/test/java/com/auth0/android/provider/OAuthManagerStateTest.kt +++ b/auth0/src/test/java/com/auth0/android/provider/OAuthManagerStateTest.kt @@ -44,4 +44,88 @@ internal class OAuthManagerStateTest { Assert.assertEquals(1, deserializedState.idTokenVerificationLeeway) Assert.assertEquals("issuer", deserializedState.idTokenVerificationIssuer) } + + @Test + fun `serialize should persist dPoPEnabled flag as true`() { + val auth0 = Auth0.getInstance("clientId", "domain") + val state = OAuthManagerState( + auth0 = auth0, + parameters = mapOf("param1" to "value1"), + headers = mapOf("header1" to "value1"), + requestCode = 1, + ctOptions = CustomTabsOptions.newBuilder() + .showTitle(true) + .withBrowserPicker( + BrowserPicker.newBuilder().withAllowedPackages(emptyList()).build() + ) + .build(), + pkce = PKCE(mock(), "redirectUri", mapOf("header1" to "value1")), + idTokenVerificationLeeway = 1, + idTokenVerificationIssuer = "issuer", + dPoPEnabled = true + ) + + val json = state.serializeToJson() + + Assert.assertTrue(json.isNotBlank()) + Assert.assertTrue(json.contains("\"dPoPEnabled\":true")) + + val deserializedState = OAuthManagerState.deserializeState(json) + + Assert.assertTrue(deserializedState.dPoPEnabled) + } + + @Test + fun `serialize should persist dPoPEnabled flag as false by default`() { + val auth0 = Auth0.getInstance("clientId", "domain") + val state = OAuthManagerState( + auth0 = auth0, + parameters = mapOf("param1" to "value1"), + headers = mapOf("header1" to "value1"), + requestCode = 1, + ctOptions = CustomTabsOptions.newBuilder() + .showTitle(true) + .withBrowserPicker( + BrowserPicker.newBuilder().withAllowedPackages(emptyList()).build() + ) + .build(), + pkce = PKCE(mock(), "redirectUri", mapOf("header1" to "value1")), + idTokenVerificationLeeway = 1, + idTokenVerificationIssuer = "issuer" + ) + + val json = state.serializeToJson() + + val deserializedState = OAuthManagerState.deserializeState(json) + + Assert.assertFalse(deserializedState.dPoPEnabled) + } + + @Test + fun `deserialize should default dPoPEnabled to false when field is missing from JSON`() { + val auth0 = Auth0.getInstance("clientId", "domain") + val state = OAuthManagerState( + auth0 = auth0, + parameters = emptyMap(), + headers = emptyMap(), + requestCode = 0, + ctOptions = CustomTabsOptions.newBuilder() + .showTitle(true) + .withBrowserPicker( + BrowserPicker.newBuilder().withAllowedPackages(emptyList()).build() + ) + .build(), + pkce = PKCE(mock(), "redirectUri", emptyMap()), + idTokenVerificationLeeway = null, + idTokenVerificationIssuer = null + ) + + val json = state.serializeToJson() + // Remove the dPoPEnabled field to simulate legacy JSON + val legacyJson = json.replace(",\"dPoPEnabled\":false", "") + + val deserializedState = OAuthManagerState.deserializeState(legacyJson) + + Assert.assertFalse(deserializedState.dPoPEnabled) + } } 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 e033aa8df..db18e6565 100644 --- a/auth0/src/test/java/com/auth0/android/provider/WebAuthProviderTest.kt +++ b/auth0/src/test/java/com/auth0/android/provider/WebAuthProviderTest.kt @@ -4,6 +4,7 @@ import android.app.Activity import android.content.Context import android.content.Intent import android.net.Uri +import android.os.Bundle import android.os.Parcelable import androidx.test.espresso.intent.matcher.IntentMatchers import androidx.test.espresso.intent.matcher.UriMatchers @@ -2958,6 +2959,27 @@ public class WebAuthProviderTest { mockAPI.shutdown() } + @Test + public fun shouldReEnableDPoPOnOAuthManagerAfterProcessDeathRestore() { + `when`(mockKeyStore.hasKeyPair()).thenReturn(true) + `when`(mockKeyStore.getKeyPair()).thenReturn(Pair(mock(), FakeECPublicKey())) + + WebAuthProvider.useDPoP(mockContext) + .login(account) + .start(activity, callback) + + val bundle = Bundle() + WebAuthProvider.onSaveInstanceState(bundle) + + // Simulate the host process being killed and recreated: the manager instance is gone, + // and the activity is recreated with the saved state. + WebAuthProvider.resetManagerInstance() + WebAuthProvider.onRestoreInstanceState(bundle, activity) + + val restoredManager = WebAuthProvider.managerInstance as OAuthManager + assertThat(restoredManager.dPoP, `is`(notNullValue())) + } + //** ** ** ** ** ** **// //** ** ** ** ** ** **// //** Helpers Functions**// From caa3b5fbcf9116fb86d4b36fa3b3c6a578011069 Mon Sep 17 00:00:00 2001 From: Prince Mathew Date: Mon, 8 Jun 2026 14:59:40 +0530 Subject: [PATCH 02/11] Addressed review comments --- .../auth0/android/provider/OAuthManager.kt | 4 +- .../android/provider/OAuthManagerState.kt | 2 +- .../android/provider/OAuthManagerStateTest.kt | 58 +++++++++++++++++++ .../android/provider/WebAuthProviderTest.kt | 4 ++ 4 files changed, 65 insertions(+), 3 deletions(-) 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 718d3a788..242a17335 100644 --- a/auth0/src/main/java/com/auth0/android/provider/OAuthManager.kt +++ b/auth0/src/main/java/com/auth0/android/provider/OAuthManager.kt @@ -394,7 +394,7 @@ internal fun OAuthManager.Companion.fromState( ): OAuthManager { // Enable DPoP on the restored PKCE's AuthenticationAPIClient so that // the token exchange request includes the DPoP proof after process restore. - if (state.dPoPEnabled && state.pkce != null) { + if (state.dPoPEnabled && state.pkce != null) { state.pkce.apiClient.useDPoP(context) } return OAuthManager( @@ -403,7 +403,7 @@ internal fun OAuthManager.Companion.fromState( parameters = state.parameters, callback = callback, customAuthorizeUrl = state.customAuthorizeUrl, - dPoP = if (state.dPoPEnabled ) DPoP(context) else null + dPoP = if (state.dPoPEnabled) DPoP(context) else null ).apply { setHeaders( state.headers diff --git a/auth0/src/main/java/com/auth0/android/provider/OAuthManagerState.kt b/auth0/src/main/java/com/auth0/android/provider/OAuthManagerState.kt index 06d0c6b0b..9f10bb8e5 100644 --- a/auth0/src/main/java/com/auth0/android/provider/OAuthManagerState.kt +++ b/auth0/src/main/java/com/auth0/android/provider/OAuthManagerState.kt @@ -36,7 +36,7 @@ internal data class OAuthManagerState( val idTokenVerificationLeeway: Int?, val idTokenVerificationIssuer: String?, val customAuthorizeUrl: String? = null, - val dPoPEnabled: Boolean + val dPoPEnabled: Boolean = false ) fun serializeToJson( diff --git a/auth0/src/test/java/com/auth0/android/provider/OAuthManagerStateTest.kt b/auth0/src/test/java/com/auth0/android/provider/OAuthManagerStateTest.kt index 0543e0a47..21ac39659 100644 --- a/auth0/src/test/java/com/auth0/android/provider/OAuthManagerStateTest.kt +++ b/auth0/src/test/java/com/auth0/android/provider/OAuthManagerStateTest.kt @@ -1,8 +1,16 @@ package com.auth0.android.provider +import android.content.Context import android.graphics.Color import com.auth0.android.Auth0 +import com.auth0.android.authentication.AuthenticationAPIClient +import com.auth0.android.authentication.AuthenticationException +import com.auth0.android.callback.Callback +import com.auth0.android.result.Credentials import com.nhaarman.mockitokotlin2.mock +import com.nhaarman.mockitokotlin2.whenever +import org.hamcrest.MatcherAssert.assertThat +import org.hamcrest.core.Is.`is` import org.junit.Assert import org.junit.Test import org.junit.runner.RunWith @@ -128,4 +136,54 @@ internal class OAuthManagerStateTest { Assert.assertFalse(deserializedState.dPoPEnabled) } + + @Test + fun `fromState should re-enable DPoP on the restored PKCE's API client when dPoPEnabled is true`() { + val context = mock() + whenever(context.applicationContext).thenReturn(context) + val auth0 = Auth0.getInstance("clientId", "domain") + val apiClient = AuthenticationAPIClient(auth0) + val state = OAuthManagerState( + auth0 = auth0, + parameters = emptyMap(), + headers = emptyMap(), + requestCode = 0, + ctOptions = CustomTabsOptions.newBuilder().build(), + pkce = PKCE(apiClient, "codeVerifier", "redirectUri", "codeChallenge", emptyMap()), + idTokenVerificationLeeway = null, + idTokenVerificationIssuer = null, + dPoPEnabled = true + ) + val callback = mock>() + + OAuthManager.fromState(state, callback, context) + + // This is the actual regression guard: the token exchange after process death only + // includes the DPoP proof because fromState re-enables DPoP on the restored API client. + assertThat(apiClient.isDPoPEnabled, `is`(true)) + } + + @Test + fun `fromState should not enable DPoP on the restored PKCE's API client when dPoPEnabled is false`() { + val context = mock() + whenever(context.applicationContext).thenReturn(context) + val auth0 = Auth0.getInstance("clientId", "domain") + val apiClient = AuthenticationAPIClient(auth0) + val state = OAuthManagerState( + auth0 = auth0, + parameters = emptyMap(), + headers = emptyMap(), + requestCode = 0, + ctOptions = CustomTabsOptions.newBuilder().build(), + pkce = PKCE(apiClient, "codeVerifier", "redirectUri", "codeChallenge", emptyMap()), + idTokenVerificationLeeway = null, + idTokenVerificationIssuer = null, + dPoPEnabled = false + ) + val callback = mock>() + + OAuthManager.fromState(state, callback, context) + + assertThat(apiClient.isDPoPEnabled, `is`(false)) + } } 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 db18e6565..dd195dbf2 100644 --- a/auth0/src/test/java/com/auth0/android/provider/WebAuthProviderTest.kt +++ b/auth0/src/test/java/com/auth0/android/provider/WebAuthProviderTest.kt @@ -2977,6 +2977,10 @@ public class WebAuthProviderTest { WebAuthProvider.onRestoreInstanceState(bundle, activity) val restoredManager = WebAuthProvider.managerInstance as OAuthManager + // This asserts the save/restore wiring reconstructs a DPoP-enabled manager. The actual + // regression guard — that DPoP is re-enabled on the restored PKCE's API client so the + // token exchange carries the proof — lives in OAuthManagerStateTest.fromState tests, + // since OAuthManager.pkce is private and not reachable here without reflection. assertThat(restoredManager.dPoP, `is`(notNullValue())) } From cee8fb1589acbd5dbcb3ebf5e056164e7891c315 Mon Sep 17 00:00:00 2001 From: Prince Mathew Date: Mon, 8 Jun 2026 12:14:15 +0530 Subject: [PATCH 03/11] fix : Handling DPoP enabled WebAuth flow after process death --- .../provider/AuthenticationActivity.kt | 2 +- .../auth0/android/provider/OAuthManager.kt | 15 +++- .../android/provider/OAuthManagerState.kt | 9 +- .../auth0/android/provider/WebAuthProvider.kt | 5 +- .../android/provider/OAuthManagerStateTest.kt | 84 +++++++++++++++++++ .../android/provider/WebAuthProviderTest.kt | 22 +++++ 6 files changed, 126 insertions(+), 11 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 5f3c3f4e6..b0f413cc6 100644 --- a/auth0/src/main/java/com/auth0/android/provider/AuthenticationActivity.kt +++ b/auth0/src/main/java/com/auth0/android/provider/AuthenticationActivity.kt @@ -40,7 +40,7 @@ public open class AuthenticationActivity : Activity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) if (savedInstanceState != null) { - WebAuthProvider.onRestoreInstanceState(savedInstanceState) + WebAuthProvider.onRestoreInstanceState(savedInstanceState, this) intentLaunched = savedInstanceState.getBoolean(EXTRA_INTENT_LAUNCHED, false) } } 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 acc2b2864..718d3a788 100644 --- a/auth0/src/main/java/com/auth0/android/provider/OAuthManager.kt +++ b/auth0/src/main/java/com/auth0/android/provider/OAuthManager.kt @@ -29,6 +29,7 @@ internal class OAuthManager( @get:VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) internal val dPoP: DPoP? = null ) : ResumableManager() { + private val parameters: MutableMap private val headers: MutableMap private val ctOptions: CustomTabsOptions @@ -211,7 +212,8 @@ internal class OAuthManager( auth0 = account, idTokenVerificationIssuer = idTokenVerificationIssuer, idTokenVerificationLeeway = idTokenVerificationLeeway, - customAuthorizeUrl = this.customAuthorizeUrl + customAuthorizeUrl = this.customAuthorizeUrl, + dPoPEnabled = dPoP != null ) } @@ -387,14 +389,21 @@ internal class OAuthManager( internal fun OAuthManager.Companion.fromState( state: OAuthManagerState, - callback: Callback + callback: Callback, + context: Context ): OAuthManager { + // Enable DPoP on the restored PKCE's AuthenticationAPIClient so that + // the token exchange request includes the DPoP proof after process restore. + if (state.dPoPEnabled && state.pkce != null) { + state.pkce.apiClient.useDPoP(context) + } return OAuthManager( account = state.auth0, ctOptions = state.ctOptions, parameters = state.parameters, callback = callback, - customAuthorizeUrl = state.customAuthorizeUrl + customAuthorizeUrl = state.customAuthorizeUrl, + dPoP = if (state.dPoPEnabled ) DPoP(context) else null ).apply { setHeaders( state.headers diff --git a/auth0/src/main/java/com/auth0/android/provider/OAuthManagerState.kt b/auth0/src/main/java/com/auth0/android/provider/OAuthManagerState.kt index ab677af6c..06d0c6b0b 100644 --- a/auth0/src/main/java/com/auth0/android/provider/OAuthManagerState.kt +++ b/auth0/src/main/java/com/auth0/android/provider/OAuthManagerState.kt @@ -6,7 +6,6 @@ import android.util.Base64 import androidx.core.os.ParcelCompat import com.auth0.android.Auth0 import com.auth0.android.authentication.AuthenticationAPIClient -import com.auth0.android.dpop.DPoP import com.auth0.android.request.internal.GsonProvider import com.google.gson.Gson @@ -20,7 +19,7 @@ internal data class OAuthManagerState( val idTokenVerificationLeeway: Int?, val idTokenVerificationIssuer: String?, val customAuthorizeUrl: String? = null, - val dPoP: DPoP? = null + val dPoPEnabled: Boolean = false ) { private class OAuthManagerJson( @@ -37,7 +36,7 @@ internal data class OAuthManagerState( val idTokenVerificationLeeway: Int?, val idTokenVerificationIssuer: String?, val customAuthorizeUrl: String? = null, - val dPoP: DPoP? = null + val dPoPEnabled: Boolean ) fun serializeToJson( @@ -62,7 +61,7 @@ internal data class OAuthManagerState( idTokenVerificationIssuer = idTokenVerificationIssuer, idTokenVerificationLeeway = idTokenVerificationLeeway, customAuthorizeUrl = this.customAuthorizeUrl, - dPoP = this.dPoP + dPoPEnabled = this.dPoPEnabled ) return gson.toJson(json) } finally { @@ -112,7 +111,7 @@ internal data class OAuthManagerState( idTokenVerificationIssuer = oauthManagerJson.idTokenVerificationIssuer, idTokenVerificationLeeway = oauthManagerJson.idTokenVerificationLeeway, customAuthorizeUrl = oauthManagerJson.customAuthorizeUrl, - dPoP = oauthManagerJson.dPoP + dPoPEnabled = oauthManagerJson.dPoPEnabled ) } finally { parcel.recycle() 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 6d647a432..a668e0704 100644 --- a/auth0/src/main/java/com/auth0/android/provider/WebAuthProvider.kt +++ b/auth0/src/main/java/com/auth0/android/provider/WebAuthProvider.kt @@ -142,7 +142,7 @@ public object WebAuthProvider : SenderConstraining { } } - internal fun onRestoreInstanceState(bundle: Bundle) { + internal fun onRestoreInstanceState(bundle: Bundle, context: Context) { if (managerInstance == null) { val oauthStateJson = bundle.getString(KEY_BUNDLE_OAUTH_MANAGER_STATE).orEmpty() val parStateJson = bundle.getString(KEY_BUNDLE_PAR_MANAGER_STATE).orEmpty() @@ -162,7 +162,8 @@ public object WebAuthProvider : SenderConstraining { callback.onFailure(error) } } - } + }, + context ) } else if (parStateJson.isNotBlank()) { val state = PARCodeManagerState.deserializeState(parStateJson) diff --git a/auth0/src/test/java/com/auth0/android/provider/OAuthManagerStateTest.kt b/auth0/src/test/java/com/auth0/android/provider/OAuthManagerStateTest.kt index e4ac82383..0543e0a47 100644 --- a/auth0/src/test/java/com/auth0/android/provider/OAuthManagerStateTest.kt +++ b/auth0/src/test/java/com/auth0/android/provider/OAuthManagerStateTest.kt @@ -44,4 +44,88 @@ internal class OAuthManagerStateTest { Assert.assertEquals(1, deserializedState.idTokenVerificationLeeway) Assert.assertEquals("issuer", deserializedState.idTokenVerificationIssuer) } + + @Test + fun `serialize should persist dPoPEnabled flag as true`() { + val auth0 = Auth0.getInstance("clientId", "domain") + val state = OAuthManagerState( + auth0 = auth0, + parameters = mapOf("param1" to "value1"), + headers = mapOf("header1" to "value1"), + requestCode = 1, + ctOptions = CustomTabsOptions.newBuilder() + .showTitle(true) + .withBrowserPicker( + BrowserPicker.newBuilder().withAllowedPackages(emptyList()).build() + ) + .build(), + pkce = PKCE(mock(), "redirectUri", mapOf("header1" to "value1")), + idTokenVerificationLeeway = 1, + idTokenVerificationIssuer = "issuer", + dPoPEnabled = true + ) + + val json = state.serializeToJson() + + Assert.assertTrue(json.isNotBlank()) + Assert.assertTrue(json.contains("\"dPoPEnabled\":true")) + + val deserializedState = OAuthManagerState.deserializeState(json) + + Assert.assertTrue(deserializedState.dPoPEnabled) + } + + @Test + fun `serialize should persist dPoPEnabled flag as false by default`() { + val auth0 = Auth0.getInstance("clientId", "domain") + val state = OAuthManagerState( + auth0 = auth0, + parameters = mapOf("param1" to "value1"), + headers = mapOf("header1" to "value1"), + requestCode = 1, + ctOptions = CustomTabsOptions.newBuilder() + .showTitle(true) + .withBrowserPicker( + BrowserPicker.newBuilder().withAllowedPackages(emptyList()).build() + ) + .build(), + pkce = PKCE(mock(), "redirectUri", mapOf("header1" to "value1")), + idTokenVerificationLeeway = 1, + idTokenVerificationIssuer = "issuer" + ) + + val json = state.serializeToJson() + + val deserializedState = OAuthManagerState.deserializeState(json) + + Assert.assertFalse(deserializedState.dPoPEnabled) + } + + @Test + fun `deserialize should default dPoPEnabled to false when field is missing from JSON`() { + val auth0 = Auth0.getInstance("clientId", "domain") + val state = OAuthManagerState( + auth0 = auth0, + parameters = emptyMap(), + headers = emptyMap(), + requestCode = 0, + ctOptions = CustomTabsOptions.newBuilder() + .showTitle(true) + .withBrowserPicker( + BrowserPicker.newBuilder().withAllowedPackages(emptyList()).build() + ) + .build(), + pkce = PKCE(mock(), "redirectUri", emptyMap()), + idTokenVerificationLeeway = null, + idTokenVerificationIssuer = null + ) + + val json = state.serializeToJson() + // Remove the dPoPEnabled field to simulate legacy JSON + val legacyJson = json.replace(",\"dPoPEnabled\":false", "") + + val deserializedState = OAuthManagerState.deserializeState(legacyJson) + + Assert.assertFalse(deserializedState.dPoPEnabled) + } } 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 e033aa8df..db18e6565 100644 --- a/auth0/src/test/java/com/auth0/android/provider/WebAuthProviderTest.kt +++ b/auth0/src/test/java/com/auth0/android/provider/WebAuthProviderTest.kt @@ -4,6 +4,7 @@ import android.app.Activity import android.content.Context import android.content.Intent import android.net.Uri +import android.os.Bundle import android.os.Parcelable import androidx.test.espresso.intent.matcher.IntentMatchers import androidx.test.espresso.intent.matcher.UriMatchers @@ -2958,6 +2959,27 @@ public class WebAuthProviderTest { mockAPI.shutdown() } + @Test + public fun shouldReEnableDPoPOnOAuthManagerAfterProcessDeathRestore() { + `when`(mockKeyStore.hasKeyPair()).thenReturn(true) + `when`(mockKeyStore.getKeyPair()).thenReturn(Pair(mock(), FakeECPublicKey())) + + WebAuthProvider.useDPoP(mockContext) + .login(account) + .start(activity, callback) + + val bundle = Bundle() + WebAuthProvider.onSaveInstanceState(bundle) + + // Simulate the host process being killed and recreated: the manager instance is gone, + // and the activity is recreated with the saved state. + WebAuthProvider.resetManagerInstance() + WebAuthProvider.onRestoreInstanceState(bundle, activity) + + val restoredManager = WebAuthProvider.managerInstance as OAuthManager + assertThat(restoredManager.dPoP, `is`(notNullValue())) + } + //** ** ** ** ** ** **// //** ** ** ** ** ** **// //** Helpers Functions**// From d0aa7c32e746c7f0cae0a587c6828db1d8025b93 Mon Sep 17 00:00:00 2001 From: Prince Mathew Date: Mon, 8 Jun 2026 14:59:40 +0530 Subject: [PATCH 04/11] Addressed review comments --- .../auth0/android/provider/OAuthManager.kt | 4 +- .../android/provider/OAuthManagerState.kt | 2 +- .../android/provider/OAuthManagerStateTest.kt | 58 +++++++++++++++++++ .../android/provider/WebAuthProviderTest.kt | 4 ++ 4 files changed, 65 insertions(+), 3 deletions(-) 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 718d3a788..242a17335 100644 --- a/auth0/src/main/java/com/auth0/android/provider/OAuthManager.kt +++ b/auth0/src/main/java/com/auth0/android/provider/OAuthManager.kt @@ -394,7 +394,7 @@ internal fun OAuthManager.Companion.fromState( ): OAuthManager { // Enable DPoP on the restored PKCE's AuthenticationAPIClient so that // the token exchange request includes the DPoP proof after process restore. - if (state.dPoPEnabled && state.pkce != null) { + if (state.dPoPEnabled && state.pkce != null) { state.pkce.apiClient.useDPoP(context) } return OAuthManager( @@ -403,7 +403,7 @@ internal fun OAuthManager.Companion.fromState( parameters = state.parameters, callback = callback, customAuthorizeUrl = state.customAuthorizeUrl, - dPoP = if (state.dPoPEnabled ) DPoP(context) else null + dPoP = if (state.dPoPEnabled) DPoP(context) else null ).apply { setHeaders( state.headers diff --git a/auth0/src/main/java/com/auth0/android/provider/OAuthManagerState.kt b/auth0/src/main/java/com/auth0/android/provider/OAuthManagerState.kt index 06d0c6b0b..9f10bb8e5 100644 --- a/auth0/src/main/java/com/auth0/android/provider/OAuthManagerState.kt +++ b/auth0/src/main/java/com/auth0/android/provider/OAuthManagerState.kt @@ -36,7 +36,7 @@ internal data class OAuthManagerState( val idTokenVerificationLeeway: Int?, val idTokenVerificationIssuer: String?, val customAuthorizeUrl: String? = null, - val dPoPEnabled: Boolean + val dPoPEnabled: Boolean = false ) fun serializeToJson( diff --git a/auth0/src/test/java/com/auth0/android/provider/OAuthManagerStateTest.kt b/auth0/src/test/java/com/auth0/android/provider/OAuthManagerStateTest.kt index 0543e0a47..21ac39659 100644 --- a/auth0/src/test/java/com/auth0/android/provider/OAuthManagerStateTest.kt +++ b/auth0/src/test/java/com/auth0/android/provider/OAuthManagerStateTest.kt @@ -1,8 +1,16 @@ package com.auth0.android.provider +import android.content.Context import android.graphics.Color import com.auth0.android.Auth0 +import com.auth0.android.authentication.AuthenticationAPIClient +import com.auth0.android.authentication.AuthenticationException +import com.auth0.android.callback.Callback +import com.auth0.android.result.Credentials import com.nhaarman.mockitokotlin2.mock +import com.nhaarman.mockitokotlin2.whenever +import org.hamcrest.MatcherAssert.assertThat +import org.hamcrest.core.Is.`is` import org.junit.Assert import org.junit.Test import org.junit.runner.RunWith @@ -128,4 +136,54 @@ internal class OAuthManagerStateTest { Assert.assertFalse(deserializedState.dPoPEnabled) } + + @Test + fun `fromState should re-enable DPoP on the restored PKCE's API client when dPoPEnabled is true`() { + val context = mock() + whenever(context.applicationContext).thenReturn(context) + val auth0 = Auth0.getInstance("clientId", "domain") + val apiClient = AuthenticationAPIClient(auth0) + val state = OAuthManagerState( + auth0 = auth0, + parameters = emptyMap(), + headers = emptyMap(), + requestCode = 0, + ctOptions = CustomTabsOptions.newBuilder().build(), + pkce = PKCE(apiClient, "codeVerifier", "redirectUri", "codeChallenge", emptyMap()), + idTokenVerificationLeeway = null, + idTokenVerificationIssuer = null, + dPoPEnabled = true + ) + val callback = mock>() + + OAuthManager.fromState(state, callback, context) + + // This is the actual regression guard: the token exchange after process death only + // includes the DPoP proof because fromState re-enables DPoP on the restored API client. + assertThat(apiClient.isDPoPEnabled, `is`(true)) + } + + @Test + fun `fromState should not enable DPoP on the restored PKCE's API client when dPoPEnabled is false`() { + val context = mock() + whenever(context.applicationContext).thenReturn(context) + val auth0 = Auth0.getInstance("clientId", "domain") + val apiClient = AuthenticationAPIClient(auth0) + val state = OAuthManagerState( + auth0 = auth0, + parameters = emptyMap(), + headers = emptyMap(), + requestCode = 0, + ctOptions = CustomTabsOptions.newBuilder().build(), + pkce = PKCE(apiClient, "codeVerifier", "redirectUri", "codeChallenge", emptyMap()), + idTokenVerificationLeeway = null, + idTokenVerificationIssuer = null, + dPoPEnabled = false + ) + val callback = mock>() + + OAuthManager.fromState(state, callback, context) + + assertThat(apiClient.isDPoPEnabled, `is`(false)) + } } 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 db18e6565..dd195dbf2 100644 --- a/auth0/src/test/java/com/auth0/android/provider/WebAuthProviderTest.kt +++ b/auth0/src/test/java/com/auth0/android/provider/WebAuthProviderTest.kt @@ -2977,6 +2977,10 @@ public class WebAuthProviderTest { WebAuthProvider.onRestoreInstanceState(bundle, activity) val restoredManager = WebAuthProvider.managerInstance as OAuthManager + // This asserts the save/restore wiring reconstructs a DPoP-enabled manager. The actual + // regression guard — that DPoP is re-enabled on the restored PKCE's API client so the + // token exchange carries the proof — lives in OAuthManagerStateTest.fromState tests, + // since OAuthManager.pkce is private and not reachable here without reflection. assertThat(restoredManager.dPoP, `is`(notNullValue())) } From ff8bf8e43c7534ff8899cb8748792dcefd42f4a9 Mon Sep 17 00:00:00 2001 From: Subhankar Maiti Date: Thu, 11 Jun 2026 20:33:05 +0530 Subject: [PATCH 05/11] fix: Implement state recovery for DPoP enabled WebAuth flow after process death --- .../auth0/android/provider/WebAuthProvider.kt | 43 ++++++++++++++++++- 1 file changed, 42 insertions(+), 1 deletion(-) 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 a668e0704..6546619a0 100644 --- a/auth0/src/main/java/com/auth0/android/provider/WebAuthProvider.kt +++ b/auth0/src/main/java/com/auth0/android/provider/WebAuthProvider.kt @@ -38,14 +38,43 @@ public object WebAuthProvider : SenderConstraining { private val callbacks = CopyOnWriteArraySet>() private val parCallbacks = CopyOnWriteArraySet>() + // Buffers a state-restore result that completed before any callback was registered (process + // death during login: the restored AuthenticationActivity finishes the token exchange before + // the host app can subscribe). Delivered to the next addCallback subscriber. + private sealed class RecoveredResult { + class Success(val credentials: Credentials) : RecoveredResult() + class Failure(val error: AuthenticationException) : RecoveredResult() + } + + private val recoveryLock = Any() + private var pendingRecovered: RecoveredResult? = null + @JvmStatic @get:VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) internal var managerInstance: ResumableManager? = null private set + /** + * Registers a callback for Universal Login results from the state-restore path + * ([onRestoreInstanceState]). A result buffered before this call is delivered immediately and + * consumed. Normal in-process logins resolve through the [Builder.start] callback, not here. + */ @JvmStatic public fun addCallback(callback: Callback) { - callbacks += callback + val buffered = synchronized(recoveryLock) { + val pending = pendingRecovered + if (pending != null) { + pendingRecovered = null + } else { + callbacks += callback + } + pending + } + when (buffered) { + is RecoveredResult.Success -> callback.onSuccess(buffered.credentials) + is RecoveredResult.Failure -> callback.onFailure(buffered.error) + null -> {} + } } @JvmStatic @@ -152,12 +181,24 @@ public object WebAuthProvider : SenderConstraining { state, object : Callback { override fun onSuccess(result: Credentials) { + synchronized(recoveryLock) { + if (callbacks.isEmpty()) { + pendingRecovered = RecoveredResult.Success(result) + return + } + } for (callback in callbacks) { callback.onSuccess(result) } } override fun onFailure(error: AuthenticationException) { + synchronized(recoveryLock) { + if (callbacks.isEmpty()) { + pendingRecovered = RecoveredResult.Failure(error) + return + } + } for (callback in callbacks) { callback.onFailure(error) } From 29ce30e68b16379d996f1dc2e4d7222107f02298 Mon Sep 17 00:00:00 2001 From: Subhankar Maiti Date: Thu, 11 Jun 2026 23:23:18 +0530 Subject: [PATCH 06/11] fix: Ensure thread-safe callback removal and notification in WebAuthProvider --- .../com/auth0/android/provider/WebAuthProvider.kt | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 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 6546619a0..0b218deb8 100644 --- a/auth0/src/main/java/com/auth0/android/provider/WebAuthProvider.kt +++ b/auth0/src/main/java/com/auth0/android/provider/WebAuthProvider.kt @@ -79,7 +79,9 @@ public object WebAuthProvider : SenderConstraining { @JvmStatic public fun removeCallback(callback: Callback) { - callbacks -= callback + synchronized(recoveryLock) { + callbacks -= callback + } } // Public methods @@ -181,25 +183,27 @@ public object WebAuthProvider : SenderConstraining { state, object : Callback { override fun onSuccess(result: Credentials) { - synchronized(recoveryLock) { + val subscribers = synchronized(recoveryLock) { if (callbacks.isEmpty()) { pendingRecovered = RecoveredResult.Success(result) return } + callbacks.toList() } - for (callback in callbacks) { + for (callback in subscribers) { callback.onSuccess(result) } } override fun onFailure(error: AuthenticationException) { - synchronized(recoveryLock) { + val subscribers = synchronized(recoveryLock) { if (callbacks.isEmpty()) { pendingRecovered = RecoveredResult.Failure(error) return } + callbacks.toList() } - for (callback in callbacks) { + for (callback in subscribers) { callback.onFailure(error) } } From e17253e5f2a2354dca3b962cb6a3dc4501bfc0c9 Mon Sep 17 00:00:00 2001 From: Subhankar Maiti Date: Fri, 12 Jun 2026 09:42:01 +0530 Subject: [PATCH 07/11] Revert "fix: Ensure thread-safe callback removal and notification in WebAuthProvider" This reverts commit 29ce30e68b16379d996f1dc2e4d7222107f02298. --- .../com/auth0/android/provider/WebAuthProvider.kt | 14 +++++--------- 1 file changed, 5 insertions(+), 9 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 0b218deb8..6546619a0 100644 --- a/auth0/src/main/java/com/auth0/android/provider/WebAuthProvider.kt +++ b/auth0/src/main/java/com/auth0/android/provider/WebAuthProvider.kt @@ -79,9 +79,7 @@ public object WebAuthProvider : SenderConstraining { @JvmStatic public fun removeCallback(callback: Callback) { - synchronized(recoveryLock) { - callbacks -= callback - } + callbacks -= callback } // Public methods @@ -183,27 +181,25 @@ public object WebAuthProvider : SenderConstraining { state, object : Callback { override fun onSuccess(result: Credentials) { - val subscribers = synchronized(recoveryLock) { + synchronized(recoveryLock) { if (callbacks.isEmpty()) { pendingRecovered = RecoveredResult.Success(result) return } - callbacks.toList() } - for (callback in subscribers) { + for (callback in callbacks) { callback.onSuccess(result) } } override fun onFailure(error: AuthenticationException) { - val subscribers = synchronized(recoveryLock) { + synchronized(recoveryLock) { if (callbacks.isEmpty()) { pendingRecovered = RecoveredResult.Failure(error) return } - callbacks.toList() } - for (callback in subscribers) { + for (callback in callbacks) { callback.onFailure(error) } } From 0d4c16f489fbf8b7075422e74316ce0918c30eeb Mon Sep 17 00:00:00 2001 From: Subhankar Maiti Date: Fri, 12 Jun 2026 09:42:01 +0530 Subject: [PATCH 08/11] Revert "fix: Implement state recovery for DPoP enabled WebAuth flow after process death" This reverts commit ff8bf8e43c7534ff8899cb8748792dcefd42f4a9. --- .../auth0/android/provider/WebAuthProvider.kt | 43 +------------------ 1 file changed, 1 insertion(+), 42 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 6546619a0..a668e0704 100644 --- a/auth0/src/main/java/com/auth0/android/provider/WebAuthProvider.kt +++ b/auth0/src/main/java/com/auth0/android/provider/WebAuthProvider.kt @@ -38,43 +38,14 @@ public object WebAuthProvider : SenderConstraining { private val callbacks = CopyOnWriteArraySet>() private val parCallbacks = CopyOnWriteArraySet>() - // Buffers a state-restore result that completed before any callback was registered (process - // death during login: the restored AuthenticationActivity finishes the token exchange before - // the host app can subscribe). Delivered to the next addCallback subscriber. - private sealed class RecoveredResult { - class Success(val credentials: Credentials) : RecoveredResult() - class Failure(val error: AuthenticationException) : RecoveredResult() - } - - private val recoveryLock = Any() - private var pendingRecovered: RecoveredResult? = null - @JvmStatic @get:VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) internal var managerInstance: ResumableManager? = null private set - /** - * Registers a callback for Universal Login results from the state-restore path - * ([onRestoreInstanceState]). A result buffered before this call is delivered immediately and - * consumed. Normal in-process logins resolve through the [Builder.start] callback, not here. - */ @JvmStatic public fun addCallback(callback: Callback) { - val buffered = synchronized(recoveryLock) { - val pending = pendingRecovered - if (pending != null) { - pendingRecovered = null - } else { - callbacks += callback - } - pending - } - when (buffered) { - is RecoveredResult.Success -> callback.onSuccess(buffered.credentials) - is RecoveredResult.Failure -> callback.onFailure(buffered.error) - null -> {} - } + callbacks += callback } @JvmStatic @@ -181,24 +152,12 @@ public object WebAuthProvider : SenderConstraining { state, object : Callback { override fun onSuccess(result: Credentials) { - synchronized(recoveryLock) { - if (callbacks.isEmpty()) { - pendingRecovered = RecoveredResult.Success(result) - return - } - } for (callback in callbacks) { callback.onSuccess(result) } } override fun onFailure(error: AuthenticationException) { - synchronized(recoveryLock) { - if (callbacks.isEmpty()) { - pendingRecovered = RecoveredResult.Failure(error) - return - } - } for (callback in callbacks) { callback.onFailure(error) } From 5f4e6082a27d3a9f451692b21e707b772713ea08 Mon Sep 17 00:00:00 2001 From: Subhankar Maiti Date: Fri, 12 Jun 2026 13:30:09 +0530 Subject: [PATCH 09/11] Add lifecycle-aware callback recovery for process deat --- .../provider/LifecycleAwareCallback.kt | 53 +++++ .../auth0/android/provider/WebAuthProvider.kt | 188 +++++++++++++++++- .../com/auth0/sample/DatabaseLoginFragment.kt | 28 ++- 3 files changed, 255 insertions(+), 14 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..e1081e491 --- /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), [delegateCallback] is set to null so + * the destroyed Activity is no longer referenced by the SDK. + * + * 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 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 delegateCallback: 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 = delegateCallback + if (cb != null) { + cb.onSuccess(result) + } else { + onDetached(result, null) + } + } + + override fun onFailure(error: AuthenticationException) { + val cb = delegateCallback + if (cb != null) { + cb.onFailure(error) + } else { + onDetached(null, error) + } + } + + override fun onDestroy(owner: LifecycleOwner) { + 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 a668e0704..82967e3f7 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,8 @@ 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.authentication.AuthenticationException import com.auth0.android.callback.Callback @@ -18,6 +20,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 @@ -43,11 +46,93 @@ public object WebAuthProvider : SenderConstraining { 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). + */ + private sealed class PendingResult { + data class Success(val result: S) : PendingResult() + data class Failure(val error: AuthenticationException) : PendingResult() + } + + private val pendingLoginResult = AtomicReference?>(null) + + private val pendingLogoutResult = AtomicReference?>(null) + + /** + * Registers login and logout callbacks for the duration of the given + * [lifecycleOwner]'s lifetime. Call this once in `onCreate()` — it covers both recovery + * scenarios automatically: + * + * - **Process death**: callbacks are registered immediately so that if the process + * was killed while the browser was open, results are delivered when the Activity is + * restored. Callbacks are 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`. + * + * Both callbacks are required to ensure results are not lost during configuration changes + * (e.g. user logs out to switch accounts and rotates during the logout flow). + * + * ```kotlin + * override fun onCreate(savedInstanceState: Bundle?) { + * super.onCreate(savedInstanceState) + * WebAuthProvider.registerCallbacks(this, loginCallback = callback, logoutCallback = voidCallback) + * } + * ``` + * + * @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 registerCallbacks( + lifecycleOwner: LifecycleOwner, + loginCallback: Callback, + logoutCallback: Callback, + ) { + // 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() + } + pendingLogoutResult.getAndSet(null)?.let { pending -> + when (pending) { + is PendingResult.Success -> logoutCallback.onSuccess(pending.result) + is PendingResult.Failure -> logoutCallback.onFailure(pending.error) + } + resetManagerInstance() + } + } + + override fun onDestroy(owner: LifecycleOwner) { + callbacks -= loginCallback + owner.lifecycle.removeObserver(this) + } + }) + } + + @Deprecated( + message = "Use registerCallbacks() instead — it registers the callback and auto-removes it when the lifecycle owner is destroyed.", + replaceWith = ReplaceWith("registerCallbacks(lifecycleOwner, loginCallback = callback, logoutCallback = logoutCallback)") + ) @JvmStatic public fun addCallback(callback: Callback) { callbacks += callback } + @Deprecated( + message = "Use registerCallbacks() instead — it auto-removes the callback when the lifecycle owner is destroyed.", + replaceWith = ReplaceWith("registerCallbacks(lifecycleOwner, loginCallback = callback, logoutCallback = logoutCallback)") + ) @JvmStatic public fun removeCallback(callback: Callback) { callbacks -= callback @@ -152,14 +237,22 @@ public object WebAuthProvider : SenderConstraining { 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)) } } }, @@ -193,6 +286,33 @@ public object WebAuthProvider : SenderConstraining { managerInstance = null } + @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) + internal fun resetState() { + managerInstance = null + callbacks.clear() + pendingLoginResult.set(null) + pendingLogoutResult.set(null) + } + + @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) + internal fun setPendingLoginResult(credentials: Credentials) { + pendingLoginResult.set(PendingResult.Success(credentials)) + } + + @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) + internal fun setPendingLogoutResult() { + pendingLogoutResult.set(PendingResult.Success(null)) + } + + @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) + internal fun hasPendingLoginResult(): Boolean = pendingLoginResult.get() != null + + @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) + internal fun hasPendingLogoutResult(): Boolean = pendingLogoutResult.get() != null + + @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) + internal fun callbacksCount(): Int = callbacks.size + public class LogoutBuilder internal constructor(private val account: Auth0) { private var scheme = "https" private var returnToUrl: String? = null @@ -290,6 +410,28 @@ public object WebAuthProvider : SenderConstraining { * @see AuthenticationException.isAuthenticationCanceled */ public fun start(context: Context, callback: Callback) { + pendingLogoutResult.set(null) + pendingLoginResult.set(null) + + val effectiveCallback = if (context is LifecycleOwner) { + LifecycleAwareCallback( + delegateCallback = 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) + } + + private fun startInternal(context: Context, callback: Callback) { resetManagerInstance() if (!ctOptions.hasCompatibleBrowser(context.packageManager)) { val ex = AuthenticationException( @@ -334,7 +476,7 @@ public object WebAuthProvider : SenderConstraining { ) { return withContext(coroutineContext) { suspendCancellableCoroutine { continuation -> - start(context, object : Callback { + startInternal(context, object : Callback { override fun onSuccess(result: Void?) { continuation.resume(Unit) } @@ -606,6 +748,38 @@ public object WebAuthProvider : SenderConstraining { public fun start( context: Context, callback: Callback + ) { + pendingLoginResult.set(null) + pendingLogoutResult.set(null) + val effectiveCallback = if (context is LifecycleOwner) { + LifecycleAwareCallback( + delegateCallback = callback, + lifecycleOwner = context as LifecycleOwner, + onDetached = { success: Credentials?, error: AuthenticationException? -> + if (callbacks.isNotEmpty()) { + if (success != null) { + for (cb in callbacks) { cb.onSuccess(success) } + } else if (error != null) { + for (cb in callbacks) { cb.onFailure(error) } + } + } else { + if (success != null) { + pendingLoginResult.set(PendingResult.Success(success)) + } else if (error != null) { + pendingLoginResult.set(PendingResult.Failure(error)) + } + } + } + ) + } else { + callback + } + startInternal(context, effectiveCallback) + } + + private fun startInternal( + context: Context, + callback: Callback ) { resetManagerInstance() if (!ctOptions.hasCompatibleBrowser(context.packageManager)) { @@ -672,7 +846,9 @@ public object WebAuthProvider : SenderConstraining { ): 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/sample/src/main/java/com/auth0/sample/DatabaseLoginFragment.kt b/sample/src/main/java/com/auth0/sample/DatabaseLoginFragment.kt index 918de531b..5e5372108 100644 --- a/sample/src/main/java/com/auth0/sample/DatabaseLoginFragment.kt +++ b/sample/src/main/java/com/auth0/sample/DatabaseLoginFragment.kt @@ -118,6 +118,17 @@ class DatabaseLoginFragment : Fragment() { } } + private val logoutCallback = object : Callback { + override fun onSuccess(result: Void?) { + Snackbar.make(requireView(), "Logged out", Snackbar.LENGTH_LONG).show() + } + + override fun onFailure(error: AuthenticationException) { + Snackbar.make(requireView(), error.getDescription(), Snackbar.LENGTH_LONG) + .show() + } + } + override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View { @@ -204,14 +215,15 @@ class DatabaseLoginFragment : Fragment() { return binding.root } - override fun onStart() { - super.onStart() - WebAuthProvider.addCallback(callback) - } - - override fun onStop() { - super.onStop() - WebAuthProvider.removeCallback(callback) + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + // Registers login + logout callbacks for the lifetime of this view. Covers + // process-death and configuration-change recovery; auto-removed on destroy. + WebAuthProvider.registerCallbacks( + viewLifecycleOwner, + loginCallback = callback, + logoutCallback = logoutCallback + ) } private suspend fun dbLoginAsync(email: String, password: String) { From 8ed70af3a68ad91487b97ddf283d12e8d117c680 Mon Sep 17 00:00:00 2001 From: Subhankar Maiti Date: Fri, 12 Jun 2026 18:37:57 +0530 Subject: [PATCH 10/11] test cases for dpop process death --- .../android/provider/WebAuthProviderTest.kt | 362 +++++++++++++++++- 1 file changed, 361 insertions(+), 1 deletion(-) 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 dd195dbf2..79b2993dd 100644 --- a/auth0/src/test/java/com/auth0/android/provider/WebAuthProviderTest.kt +++ b/auth0/src/test/java/com/auth0/android/provider/WebAuthProviderTest.kt @@ -6,6 +6,9 @@ import android.content.Intent import android.net.Uri import android.os.Bundle import android.os.Parcelable +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleOwner import androidx.test.espresso.intent.matcher.IntentMatchers import androidx.test.espresso.intent.matcher.UriMatchers import com.auth0.android.Auth0 @@ -114,6 +117,9 @@ public class WebAuthProviderTest { ) `when`(mockKeyStore.hasKeyPair()).thenReturn(false) + + // Clear any state left over from previous tests + WebAuthProvider.resetState() } @@ -3132,4 +3138,358 @@ public class WebAuthProviderTest { assertThat(codeCaptor.firstValue.code, `is`("test-code")) assertThat(codeCaptor.firstValue.state, `is`("test-state")) } -} \ No newline at end of file + + // --- LifecycleAwareCallback tests --- + + @Test + public fun shouldInvokeOnDetachedWithLoginResultAfterDestroy() { + val credentials = Mockito.mock(Credentials::class.java) + var capturedSuccess: Credentials? = null + var capturedError: AuthenticationException? = null + + val lifecycleOwner = Mockito.mock(LifecycleOwner::class.java) + val lifecycle = Mockito.mock(Lifecycle::class.java) + Mockito.`when`(lifecycleOwner.lifecycle).thenReturn(lifecycle) + + val lifecycleCallback = LifecycleAwareCallback( + delegateCallback = callback, + lifecycleOwner = lifecycleOwner, + onDetached = { success, error -> + capturedSuccess = success + capturedError = error + } + ) + + lifecycleCallback.onDestroy(lifecycleOwner) + lifecycleCallback.onSuccess(credentials) + + Assert.assertEquals(credentials, capturedSuccess) + Assert.assertNull(capturedError) + verify(callback, Mockito.never()).onSuccess(any()) + } + + @Test + public fun shouldInvokeOnDetachedWithLoginFailureAfterDestroy() { + val error = AuthenticationException("canceled", "User canceled") + var capturedSuccess: Credentials? = null + var capturedError: AuthenticationException? = null + + val lifecycleOwner = Mockito.mock(LifecycleOwner::class.java) + val lifecycle = Mockito.mock(Lifecycle::class.java) + Mockito.`when`(lifecycleOwner.lifecycle).thenReturn(lifecycle) + + val lifecycleCallback = LifecycleAwareCallback( + delegateCallback = callback, + lifecycleOwner = lifecycleOwner, + onDetached = { success, detachedError -> + capturedSuccess = success + capturedError = detachedError + } + ) + + lifecycleCallback.onDestroy(lifecycleOwner) + lifecycleCallback.onFailure(error) + + 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) + Mockito.`when`(lifecycleOwner.lifecycle).thenReturn(lifecycle) + + val lifecycleCallback = LifecycleAwareCallback( + delegateCallback = callback, + lifecycleOwner = lifecycleOwner, + onDetached = { _, _ -> onDetachedCalled = true } + ) + + lifecycleCallback.onSuccess(credentials) + + verify(callback).onSuccess(credentials) + 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) + Mockito.`when`(lifecycleOwner.lifecycle).thenReturn(lifecycle) + + val lifecycleCallback = LifecycleAwareCallback( + delegateCallback = callback, + lifecycleOwner = lifecycleOwner, + onDetached = { _, _ -> onDetachedCalled = true } + ) + + lifecycleCallback.onFailure(error) + + verify(callback).onFailure(error) + Assert.assertFalse(onDetachedCalled) + } + + @Test + public fun shouldInvokeOnDetachedWithLogoutSuccessAfterDestroy() { + var onDetachedCalled = false + var capturedError: AuthenticationException? = null + + val lifecycleOwner = Mockito.mock(LifecycleOwner::class.java) + val lifecycle = Mockito.mock(Lifecycle::class.java) + Mockito.`when`(lifecycleOwner.lifecycle).thenReturn(lifecycle) + + val lifecycleCallback = LifecycleAwareCallback( + delegateCallback = voidCallback, + lifecycleOwner = lifecycleOwner, + onDetached = { _, error -> + onDetachedCalled = true + capturedError = error + } + ) + + lifecycleCallback.onDestroy(lifecycleOwner) + lifecycleCallback.onSuccess(null) + + Assert.assertTrue(onDetachedCalled) + Assert.assertNull(capturedError) + verify(voidCallback, Mockito.never()).onSuccess(any()) + } + + @Test + public fun shouldInvokeOnDetachedWithLogoutFailureAfterDestroy() { + val error = AuthenticationException("canceled", "User closed the browser") + var onDetachedCalled = false + var capturedError: AuthenticationException? = null + + val lifecycleOwner = Mockito.mock(LifecycleOwner::class.java) + val lifecycle = Mockito.mock(Lifecycle::class.java) + Mockito.`when`(lifecycleOwner.lifecycle).thenReturn(lifecycle) + + val lifecycleCallback = LifecycleAwareCallback( + delegateCallback = voidCallback, + lifecycleOwner = lifecycleOwner, + onDetached = { _, detachedError -> + onDetachedCalled = true + capturedError = detachedError + } + ) + + lifecycleCallback.onDestroy(lifecycleOwner) + lifecycleCallback.onFailure(error) + + Assert.assertTrue(onDetachedCalled) + 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) + + val lifecycleCallback = LifecycleAwareCallback( + delegateCallback = voidCallback, + lifecycleOwner = lifecycleOwner, + onDetached = { _, _ -> onDetachedCalled = true } + ) + + lifecycleCallback.onSuccess(null) + + verify(voidCallback).onSuccess(null) + Assert.assertFalse(onDetachedCalled) + } + + @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( + delegateCallback = 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( + delegateCallback = callback, + lifecycleOwner = lifecycleOwner, + onDetached = { _, _ -> } + ) + + lifecycleCallback.onDestroy(lifecycleOwner) + + verify(lifecycle).removeObserver(lifecycleCallback) + } + + + @Test + public fun shouldWrapCallbackWithLifecycleAwareCallbackWhenStartedWithLifecycleOwner() { + val lifecycle = Mockito.mock(Lifecycle::class.java) + val lifecycleActivity = Mockito.mock(TestLifecycleOwnerActivity::class.java) + Mockito.`when`(lifecycleActivity.lifecycle).thenReturn(lifecycle) + Mockito.`when`(lifecycleActivity.applicationContext).thenReturn(lifecycleActivity) + Mockito.`when`(lifecycleActivity.packageName).thenReturn("com.auth0.test") + Mockito.doReturn(false).`when`(lifecycleActivity).bindService(any(), any(), ArgumentMatchers.anyInt()) + BrowserPickerTest.setupBrowserContext(lifecycleActivity, listOf("com.auth0.browser"), null, null) + + login(account).start(lifecycleActivity, callback) + + verify(lifecycle).addObserver(any>()) + } + + + @Test + public fun shouldRegisterLifecycleObserverOnRegisterCallbacks() { + val lifecycleOwner = Mockito.mock(LifecycleOwner::class.java) + val lifecycle = Mockito.mock(Lifecycle::class.java) + Mockito.`when`(lifecycleOwner.lifecycle).thenReturn(lifecycle) + + WebAuthProvider.registerCallbacks(lifecycleOwner, loginCallback = callback, logoutCallback = voidCallback) + + verify(lifecycle).addObserver(isA()) + } + + @Test + public fun shouldRemoveLifecycleObserverOnDestroyAfterRegisterCallbacks() { + val lifecycleOwner = Mockito.mock(LifecycleOwner::class.java) + val lifecycle = Mockito.mock(Lifecycle::class.java) + Mockito.`when`(lifecycleOwner.lifecycle).thenReturn(lifecycle) + + val observerCaptor = argumentCaptor() + WebAuthProvider.registerCallbacks(lifecycleOwner, loginCallback = callback, logoutCallback = voidCallback) + verify(lifecycle).addObserver(observerCaptor.capture()) + + observerCaptor.firstValue.onDestroy(lifecycleOwner) + + verify(lifecycle).removeObserver(observerCaptor.firstValue) + } + + @Test + public fun shouldRemoveLoginCallbackFromCallbacksOnDestroy() { + val lifecycleOwner = Mockito.mock(LifecycleOwner::class.java) + val lifecycle = Mockito.mock(Lifecycle::class.java) + Mockito.`when`(lifecycleOwner.lifecycle).thenReturn(lifecycle) + + val observerCaptor = argumentCaptor() + WebAuthProvider.registerCallbacks(lifecycleOwner, loginCallback = callback, logoutCallback = voidCallback) + verify(lifecycle).addObserver(observerCaptor.capture()) + + Assert.assertEquals(1, WebAuthProvider.callbacksCount()) + + observerCaptor.firstValue.onDestroy(lifecycleOwner) + + Assert.assertEquals(0, WebAuthProvider.callbacksCount()) + } + + + @Test + public fun shouldDeliverPendingLoginResultOnResume() { + val credentials = Mockito.mock(Credentials::class.java) + WebAuthProvider.setPendingLoginResult(credentials) + + val lifecycleOwner = Mockito.mock(LifecycleOwner::class.java) + val lifecycle = Mockito.mock(Lifecycle::class.java) + Mockito.`when`(lifecycleOwner.lifecycle).thenReturn(lifecycle) + + val observerCaptor = argumentCaptor() + WebAuthProvider.registerCallbacks(lifecycleOwner, loginCallback = callback, logoutCallback = voidCallback) + verify(lifecycle).addObserver(observerCaptor.capture()) + + observerCaptor.firstValue.onResume(lifecycleOwner) + + verify(callback).onSuccess(credentials) + Assert.assertFalse(WebAuthProvider.hasPendingLoginResult()) + } + + @Test + public fun shouldDeliverPendingLogoutResultOnResume() { + WebAuthProvider.setPendingLogoutResult() + + val lifecycleOwner = Mockito.mock(LifecycleOwner::class.java) + val lifecycle = Mockito.mock(Lifecycle::class.java) + Mockito.`when`(lifecycleOwner.lifecycle).thenReturn(lifecycle) + + val observerCaptor = argumentCaptor() + WebAuthProvider.registerCallbacks(lifecycleOwner, loginCallback = callback, logoutCallback = voidCallback) + verify(lifecycle).addObserver(observerCaptor.capture()) + + observerCaptor.firstValue.onResume(lifecycleOwner) + + verify(voidCallback).onSuccess(null) + Assert.assertFalse(WebAuthProvider.hasPendingLogoutResult()) + } + + @Test + public fun shouldNotDeliverLoginResultOnResumeWhenNoPendingResult() { + val lifecycleOwner = Mockito.mock(LifecycleOwner::class.java) + val lifecycle = Mockito.mock(Lifecycle::class.java) + Mockito.`when`(lifecycleOwner.lifecycle).thenReturn(lifecycle) + + val observerCaptor = argumentCaptor() + WebAuthProvider.registerCallbacks(lifecycleOwner, loginCallback = callback, logoutCallback = voidCallback) + verify(lifecycle).addObserver(observerCaptor.capture()) + + observerCaptor.firstValue.onResume(lifecycleOwner) + verify(callback, Mockito.never()).onSuccess(any()) + verify(callback, Mockito.never()).onFailure(any()) + } + + @Test + public fun shouldNotDeliverLogoutResultOnResumeWhenNoPendingResult() { + val lifecycleOwner = Mockito.mock(LifecycleOwner::class.java) + val lifecycle = Mockito.mock(Lifecycle::class.java) + Mockito.`when`(lifecycleOwner.lifecycle).thenReturn(lifecycle) + + val observerCaptor = argumentCaptor() + WebAuthProvider.registerCallbacks(lifecycleOwner, loginCallback = callback, logoutCallback = voidCallback) + verify(lifecycle).addObserver(observerCaptor.capture()) + + observerCaptor.firstValue.onResume(lifecycleOwner) + verify(voidCallback, Mockito.never()).onSuccess(any()) + verify(voidCallback, Mockito.never()).onFailure(any()) + } + + @Test + public fun shouldClearPendingLoginResultOnNewLoginStart() { + val credentials = Mockito.mock(Credentials::class.java) + WebAuthProvider.setPendingLoginResult(credentials) + Assert.assertTrue(WebAuthProvider.hasPendingLoginResult()) + + login(account).start(activity, callback) + + Assert.assertFalse(WebAuthProvider.hasPendingLoginResult()) + } + + @Test + public fun shouldClearPendingLogoutResultOnNewLogoutStart() { + WebAuthProvider.setPendingLogoutResult() + Assert.assertTrue(WebAuthProvider.hasPendingLogoutResult()) + + logout(account).start(activity, voidCallback) + + Assert.assertFalse(WebAuthProvider.hasPendingLogoutResult()) + } +} + +internal abstract class TestLifecycleOwnerActivity : Activity(), LifecycleOwner From 44f2d1a5596a51adf28aef12040da0c270d6e68c Mon Sep 17 00:00:00 2001 From: Subhankar Maiti Date: Fri, 12 Jun 2026 18:50:43 +0530 Subject: [PATCH 11/11] fix: Add lifecycle-common dependency for improved lifecycle management --- auth0/build.gradle | 1 + 1 file changed, 1 insertion(+) diff --git a/auth0/build.gradle b/auth0/build.gradle index edb14a9a3..696042145 100644 --- a/auth0/build.gradle +++ b/auth0/build.gradle @@ -88,6 +88,7 @@ dependencies { implementation 'androidx.core:core-ktx:1.6.0' implementation 'androidx.appcompat:appcompat:1.6.0' implementation 'androidx.browser:browser:1.8.0' + implementation 'androidx.lifecycle:lifecycle-common:2.5.1' implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutinesVersion" implementation "com.squareup.okhttp3:okhttp:$okhttpVersion"