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
2 changes: 2 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,12 @@ on:
branches:
- main
- release/3.x
- v3.21.2
push:
branches:
- main
- release/3.x
- v3.21.2

permissions:
contents: read
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 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
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.Auth0
import com.auth0.android.NetworkErrorException
Expand Down Expand Up @@ -1181,6 +1182,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 =

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The bug was that a biometric session got started even though the app saw a failure. Could we also check the session here: invalid after lockout, valid after success?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added case for the same

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 @@ -4144,6 +4230,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(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we add one test of the callback API (getCredentials(callback)) using it, checking that onSuccess is called exactly once and onFailure is never called? That's the path react-native-auth0 actually uses, and right now it's only covered at the LocalAuthenticationManager level.

@pmathew92 pmathew92 Oct 21, 2026 •

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added in ac017cb: shouldDeliverOnlySuccessAndStartBiometricSessionWhenUnrecognizedBiometricIsFollowedBySuccess drives getCredentials(callback) through a real LocalAuthenticationManager (not recognized → success) and verifies onSuccess exactly once and onFailure never. It also runs under BiometricPolicy.Session and asserts the session is valid afterwards .

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