From 8a9896c5c73b886ecead28d926ee1950643470a5 Mon Sep 17 00:00:00 2001 From: Prince Mathew Date: Mon, 19 Oct 2026 20:59:51 +0530 Subject: [PATCH 1/3] fix : Fixed the biometric prompt calling onAuthenticationFailed callback when a wrong fingerprint/pin is entered --- .../storage/LocalAuthenticationManager.kt | 5 -- .../storage/LocalAuthenticationManagerTest.kt | 46 +++++++++++- .../storage/SecureCredentialsManagerTest.kt | 75 +++++++++++++++++++ 3 files changed, 119 insertions(+), 7 deletions(-) diff --git a/auth0/src/main/java/com/auth0/android/authentication/storage/LocalAuthenticationManager.kt b/auth0/src/main/java/com/auth0/android/authentication/storage/LocalAuthenticationManager.kt index 3b7e81965..d73d1ef69 100644 --- a/auth0/src/main/java/com/auth0/android/authentication/storage/LocalAuthenticationManager.kt +++ b/auth0/src/main/java/com/auth0/android/authentication/storage/LocalAuthenticationManager.kt @@ -76,11 +76,6 @@ internal class LocalAuthenticationManager( biometricPrompt.authenticate(biometricPromptInfo) } - override fun onAuthenticationFailed() { - super.onAuthenticationFailed() - resultCallback.onFailure(CredentialsManagerException.BIOMETRICS_INVALID_USER) - } - override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) { super.onAuthenticationSucceeded(result) resultCallback.onSuccess(true) diff --git a/auth0/src/test/java/com/auth0/android/authentication/storage/LocalAuthenticationManagerTest.kt b/auth0/src/test/java/com/auth0/android/authentication/storage/LocalAuthenticationManagerTest.kt index 91f1ea557..63ec726a9 100644 --- a/auth0/src/test/java/com/auth0/android/authentication/storage/LocalAuthenticationManagerTest.kt +++ b/auth0/src/test/java/com/auth0/android/authentication/storage/LocalAuthenticationManagerTest.kt @@ -7,7 +7,9 @@ import com.auth0.android.callback.Callback import org.mockito.kotlin.KArgumentCaptor import org.mockito.kotlin.any import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.never import org.mockito.kotlin.verify +import org.mockito.kotlin.verifyNoMoreInteractions import org.hamcrest.MatcherAssert import org.hamcrest.Matchers import org.hamcrest.core.Is @@ -360,7 +362,9 @@ public class LocalAuthenticationManagerTest { } @Test - public fun testInvalidAuthenticationScenario() { + public fun testUnrecognizedBiometricDoesNotDeliverAResult() { + Mockito.`when`(biometricManager.canAuthenticate(any())) + .thenReturn(BiometricManager.BIOMETRIC_SUCCESS) val localAuthenticationManager = LocalAuthenticationManager( fragmentActivity, getAuthenticationOptions(), @@ -369,10 +373,48 @@ public class LocalAuthenticationManagerTest { ) localAuthenticationManager.authenticate() + localAuthenticationManager.onAuthenticationFailed() + verifyNoMoreInteractions(callback) + } + + @Test + public fun testUnrecognizedBiometricFollowedBySuccessDeliversOnlySuccess() { Mockito.`when`(biometricManager.canAuthenticate(any())) .thenReturn(BiometricManager.BIOMETRIC_SUCCESS) + val localAuthenticationManager = LocalAuthenticationManager( + fragmentActivity, + getAuthenticationOptions(), + biometricManager, + callback + ) + + localAuthenticationManager.authenticate() localAuthenticationManager.onAuthenticationFailed() - verify(callback).onFailure(CredentialsManagerException.BIOMETRICS_INVALID_USER) + localAuthenticationManager.onAuthenticationSucceeded( + Mockito.mock(BiometricPrompt.AuthenticationResult::class.java) + ) + verify(callback).onSuccess(true) + verify(callback, never()).onFailure(any()) + } + + @Test + public fun testUnrecognizedBiometricFollowedByErrorDeliversOnlyThatError() { + Mockito.`when`(biometricManager.canAuthenticate(any())) + .thenReturn(BiometricManager.BIOMETRIC_SUCCESS) + val localAuthenticationManager = LocalAuthenticationManager( + fragmentActivity, + getAuthenticationOptions(), + biometricManager, + callback + ) + + localAuthenticationManager.authenticate() + localAuthenticationManager.onAuthenticationFailed() + localAuthenticationManager.onAuthenticationError( + BiometricPrompt.ERROR_LOCKOUT, "Too many attempts" + ) + verify(callback).onFailure(CredentialsManagerException.BIOMETRIC_ERROR_LOCKOUT) + verifyNoMoreInteractions(callback) } @Test diff --git a/auth0/src/test/java/com/auth0/android/authentication/storage/SecureCredentialsManagerTest.kt b/auth0/src/test/java/com/auth0/android/authentication/storage/SecureCredentialsManagerTest.kt index f67f87297..e1d39f7a2 100644 --- a/auth0/src/test/java/com/auth0/android/authentication/storage/SecureCredentialsManagerTest.kt +++ b/auth0/src/test/java/com/auth0/android/authentication/storage/SecureCredentialsManagerTest.kt @@ -4,6 +4,7 @@ import android.app.Activity import android.app.KeyguardManager import android.content.Context import android.util.Base64 +import androidx.biometric.BiometricPrompt import androidx.fragment.app.FragmentActivity import com.auth0.android.NetworkErrorException import com.auth0.android.authentication.AuthenticationAPIClient @@ -1196,6 +1197,55 @@ public class SecureCredentialsManagerTest { ) } + @Test + @ExperimentalCoroutinesApi + public fun shouldAwaitCredentialsOnceWhenUnrecognizedBiometricIsFollowedBySuccess(): Unit = + runTest { + simulatePromptWithUnrecognizedBiometric { prompt -> + prompt.onAuthenticationSucceeded(mock()) + } + verifyNoMoreInteractions(client) + val expiresAt = Date(CredentialsMock.CURRENT_TIME_MS + ONE_HOUR_SECONDS * 1000) + insertTestCredentials(true, true, true, expiresAt, "scope") + val retrievedCredentials = manager.awaitCredentials() + MatcherAssert.assertThat(retrievedCredentials.accessToken, Is.`is`("accessToken")) + MatcherAssert.assertThat(retrievedCredentials.idToken, Is.`is`("idToken")) + } + + @Test + @ExperimentalCoroutinesApi + public fun shouldThrowOnlyTheFinalErrorOnAwaitCredentialsWhenUnrecognizedBiometricIsFollowedByError(): Unit = + runTest { + simulatePromptWithUnrecognizedBiometric { prompt -> + prompt.onAuthenticationError(BiometricPrompt.ERROR_LOCKOUT, "Too many attempts") + } + verifyNoMoreInteractions(client) + val expiresAt = Date(CredentialsMock.CURRENT_TIME_MS + ONE_HOUR_SECONDS * 1000) + insertTestCredentials(true, true, true, expiresAt, "scope") + val exception = assertThrows(CredentialsManagerException::class.java) { + runBlocking { manager.awaitCredentials() } + } + MatcherAssert.assertThat( + exception, + Is.`is`(CredentialsManagerException.BIOMETRIC_ERROR_LOCKOUT) + ) + } + + @Test + @ExperimentalCoroutinesApi + public fun shouldAwaitApiCredentialsOnceWhenUnrecognizedBiometricIsFollowedBySuccess(): Unit = + runTest { + simulatePromptWithUnrecognizedBiometric { prompt -> + prompt.onAuthenticationSucceeded(mock()) + } + verifyNoMoreInteractions(client) + insertTestApiCredentials( + "audience1", true, Date(CredentialsMock.ONE_HOUR_AHEAD_MS), "scope" + ) + val retrievedCredentials = manager.awaitApiCredentials("audience1") + Assert.assertEquals("accessToken", retrievedCredentials.accessToken) + } + @Test public fun shouldGetNonExpiredCredentialsFromStorageWhenOnlyIdTokenIsAvailable() { Mockito.`when`(localAuthenticationManager.authenticate()).then { @@ -4583,6 +4633,31 @@ public class SecureCredentialsManagerTest { private const val KEY_ALIAS = "com.auth0.key" } + /** + * Makes [factory] return a real [LocalAuthenticationManager] whose prompt reports an + * unrecognized biometric before delivering [finalResult], mirroring how BiometricPrompt + * keeps the prompt open after a failed attempt. + */ + private fun simulatePromptWithUnrecognizedBiometric( + finalResult: (LocalAuthenticationManager) -> Unit + ) { + Mockito.`when`(factory.create(any(), any(), any())).thenAnswer { invocation -> + val prompt = Mockito.spy( + LocalAuthenticationManager( + activity = invocation.arguments[0] as FragmentActivity, + authenticationOptions = invocation.arguments[1] as LocalAuthenticationOptions, + biometricManager = mock(), + resultCallback = invocation.arguments[2] as Callback + ) + ) + Mockito.doAnswer { + prompt.onAuthenticationFailed() + finalResult(prompt) + }.`when`(prompt).authenticate() + return@thenAnswer prompt + } + } + private fun getAuthenticationOptions( title: String = "title", description: String = "description", From 62a6da0a002972b268625640b3204c42baea9cf7 Mon Sep 17 00:00:00 2001 From: Prince Mathew Date: Wed, 21 Oct 2026 10:53:59 +0530 Subject: [PATCH 2/3] Added test for a missing case --- .../storage/SecureCredentialsManagerTest.kt | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/auth0/src/test/java/com/auth0/android/authentication/storage/SecureCredentialsManagerTest.kt b/auth0/src/test/java/com/auth0/android/authentication/storage/SecureCredentialsManagerTest.kt index e1d39f7a2..25fb702cd 100644 --- a/auth0/src/test/java/com/auth0/android/authentication/storage/SecureCredentialsManagerTest.kt +++ b/auth0/src/test/java/com/auth0/android/authentication/storage/SecureCredentialsManagerTest.kt @@ -1246,6 +1246,42 @@ public class SecureCredentialsManagerTest { Assert.assertEquals("accessToken", retrievedCredentials.accessToken) } + @Test + public fun shouldDeliverOnlySuccessAndStartBiometricSessionWhenUnrecognizedBiometricIsFollowedBySuccess() { + simulatePromptWithUnrecognizedBiometric { prompt -> + prompt.onAuthenticationSucceeded(mock()) + } + val sessionOptions = LocalAuthenticationOptions.Builder() + .setTitle("title") + .setAuthenticationLevel(AuthenticationLevel.STRONG) + .setNegativeButtonText("negativeButtonText") + .setPolicy(BiometricPolicy.Session(300)) + .build() + val sessionManager = Mockito.spy( + SecureCredentialsManager( + client, + storage, + crypto, + jwtDecoder, + serialExecutor, + weakFragmentActivity, + sessionOptions, + factory + ) + ) + Mockito.doReturn(CredentialsMock.CURRENT_TIME_MS).`when`(sessionManager).currentTimeInMillis + verifyNoMoreInteractions(client) + val expiresAt = Date(CredentialsMock.CURRENT_TIME_MS + ONE_HOUR_SECONDS * 1000) + insertTestCredentials(true, true, true, expiresAt, "scope") + + sessionManager.getCredentials(callback) + + verify(callback, times(1)).onSuccess(credentialsCaptor.capture()) + verify(callback, never()).onFailure(any()) + MatcherAssert.assertThat(credentialsCaptor.firstValue.accessToken, Is.`is`("accessToken")) + MatcherAssert.assertThat(sessionManager.isBiometricSessionValid(), Is.`is`(true)) + } + @Test public fun shouldGetNonExpiredCredentialsFromStorageWhenOnlyIdTokenIsAvailable() { Mockito.`when`(localAuthenticationManager.authenticate()).then { From 932719185e6223559a59b198676176fbf5f30849 Mon Sep 17 00:00:00 2001 From: Prince Mathew Date: Wed, 21 Oct 2026 11:52:56 +0530 Subject: [PATCH 3/3] docs(changelog): retain 3.21.2 release history Co-Authored-By: Claude --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index fd8c6186a..3ebe491ed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Change Log +## [3.21.2](https://github.com/auth0/Auth0.Android/tree/3.21.2) (2026-10-21) +[Full Changelog](https://github.com/auth0/Auth0.Android/compare/3.21.1...3.21.2) + +**Fixed** +- fix: stop reporting an unrecognized biometric as a final failure [\#1098](https://github.com/auth0/Auth0.Android/pull/1098) ([pmathew92](https://github.com/pmathew92)) + ## [3.21.1](https://github.com/auth0/Auth0.Android/tree/3.21.1) (2026-10-13) [Full Changelog](https://github.com/auth0/Auth0.Android/compare/3.21.0...3.21.1)