Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(),
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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<Boolean, CredentialsManagerException>
)
)
Mockito.doAnswer {
prompt.onAuthenticationFailed()
finalResult(prompt)
}.`when`(prompt).authenticate()
return@thenAnswer prompt
}
}

private fun getAuthenticationOptions(
title: String = "title",
description: String = "description",
Expand Down
Loading