diff --git a/CHANGELOG.md b/CHANGELOG.md index fd8c6186..3ebe491e 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) 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 3b7e8196..d73d1ef6 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 91f1ea55..63ec726a 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 f67f8729..25fb702c 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,91 @@ 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 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 { @@ -4583,6 +4669,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",