From 0c4742c98956a5c493b133a3b75c45fece786690 Mon Sep 17 00:00:00 2001 From: Prince Mathew Date: Mon, 19 Oct 2026 20:59:51 +0530 Subject: [PATCH 1/7] 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 53c14baf1..917ffb2d8 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 com.nhaarman.mockitokotlin2.KArgumentCaptor import com.nhaarman.mockitokotlin2.any import com.nhaarman.mockitokotlin2.argumentCaptor +import com.nhaarman.mockitokotlin2.never import com.nhaarman.mockitokotlin2.verify +import com.nhaarman.mockitokotlin2.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 109c993f2..bb062aa3d 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.Auth0 import com.auth0.android.NetworkErrorException @@ -1181,6 +1182,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 { @@ -4144,6 +4194,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 ad8acec926f0eff35828e6531ec14eaded3da858 Mon Sep 17 00:00:00 2001 From: Prince Mathew Date: Mon, 19 Oct 2026 21:08:15 +0530 Subject: [PATCH 2/7] Tests run on all PRs raised against a branch --- .github/workflows/test.yml | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 34c04716b..87a83fb44 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -5,12 +5,10 @@ on: workflow_dispatch: pull_request: branches: - - main - - release/3.x + - * push: branches: - main - - release/3.x permissions: contents: read From c629d232e9e462234ab8203f6c7a75f2584a645b Mon Sep 17 00:00:00 2001 From: Prince Mathew Date: Mon, 19 Oct 2026 21:13:16 +0530 Subject: [PATCH 3/7] fixed minor ci bug --- .github/workflows/test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 87a83fb44..5a0bb9f03 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -5,7 +5,7 @@ on: workflow_dispatch: pull_request: branches: - - * + - '**' push: branches: - main From 06ce4430e1a0396711584ccc867c5da9ee373341 Mon Sep 17 00:00:00 2001 From: Prince Mathew Date: Mon, 19 Oct 2026 21:28:47 +0530 Subject: [PATCH 4/7] minor change --- .github/workflows/test.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 5a0bb9f03..43a65bdd3 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -9,6 +9,8 @@ on: push: branches: - main + - release/3.x + - 'v3.*' permissions: contents: read From da3b909505941965e672733e31f8b7daa9977e83 Mon Sep 17 00:00:00 2001 From: Prince Mathew Date: Mon, 19 Oct 2026 21:30:20 +0530 Subject: [PATCH 5/7] Ci change for triggering --- .github/workflows/test.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 43a65bdd3..39120b380 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -5,7 +5,9 @@ on: workflow_dispatch: pull_request: branches: - - '**' + - main + - release/3.x + - 'v3.*' push: branches: - main From d2e05c8094aa2f07bf41bd64aef89d181f897e6f Mon Sep 17 00:00:00 2001 From: Prince Mathew Date: Mon, 19 Oct 2026 21:35:05 +0530 Subject: [PATCH 6/7] Using exact version for ci trigger --- .github/workflows/test.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 39120b380..5b4176b9c 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -7,12 +7,12 @@ on: branches: - main - release/3.x - - 'v3.*' + - v3.21.2 push: branches: - main - release/3.x - - 'v3.*' + - v3.21.2 permissions: contents: read From ac017cb70a8ee2e959478662b14bbe5e51963e4c Mon Sep 17 00:00:00 2001 From: Prince Mathew Date: Wed, 21 Oct 2026 10:53:59 +0530 Subject: [PATCH 7/7] 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 bb062aa3d..52748d622 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 @@ -1231,6 +1231,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 {