Repository navigation
fix: stop reporting an unrecognized biometric as a final failure #1098
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
0c4742c
ad8acec
c629d23
06ce443
da3b909
d2e05c8
ac017cb
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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,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 { | ||
|
|
@@ -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( | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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.
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Added in ac017cb: |
||
| 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", | ||
|
|
||
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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