diff --git a/EXAMPLES.md b/EXAMPLES.md
index f5b99c44a..a7936901f 100644
--- a/EXAMPLES.md
+++ b/EXAMPLES.md
@@ -1393,6 +1393,7 @@ val localAuthenticationOptions =
LocalAuthenticationOptions.Builder().setTitle("Authenticate").setDescription("Accessing Credentials")
.setAuthenticationLevel(AuthenticationLevel.STRONG).setNegativeButtonText("Cancel")
.setDeviceCredentialFallback(true)
+ .setPolicy(BiometricPolicy.Session(300)) // Optional: Use session-based policy (5 minutes)
.build()
val storage = SharedPreferencesStorage(this)
val manager = SecureCredentialsManager(
@@ -1409,6 +1410,7 @@ LocalAuthenticationOptions localAuthenticationOptions =
new LocalAuthenticationOptions.Builder().setTitle("Authenticate").setDescription("Accessing Credentials")
.setAuthenticationLevel(AuthenticationLevel.STRONG).setNegativeButtonText("Cancel")
.setDeviceCredentialFallback(true)
+ .setPolicy(new BiometricPolicy.Session(300)) // Optional: Use session-based policy (5 minutes)
.build();
Storage storage = new SharedPreferencesStorage(context);
SecureCredentialsManager secureCredentialsManager = new SecureCredentialsManager(
@@ -1433,6 +1435,7 @@ On Android API 28 and 29, specifying **STRONG** as the authentication level alon
- **setAuthenticationLevel(authenticationLevel: AuthenticationLevel): Builder** - Sets the authentication level, more on this can be found [here](#authenticationlevel-enum-values)
- **setDeviceCredentialFallback(enableDeviceCredentialFallback: Boolean): Builder** - Enables/disables device credential fallback.
- **setNegativeButtonText(negativeButtonText: String): Builder** - Sets the negative button text, used only when the device credential fallback is disabled (or) the authentication level is not set to `AuthenticationLevel.DEVICE_CREDENTIAL`.
+- **setPolicy(policy: BiometricPolicy): Builder** - Sets the biometric policy that controls when biometric authentication is required. See [BiometricPolicy Types](#biometricpolicy-types) for more details.
- **build(): LocalAuthenticationOptions** - Constructs the LocalAuthenticationOptions instance.
@@ -1446,6 +1449,80 @@ AuthenticationLevel is an enum that defines the different levels of authenticati
- **DEVICE_CREDENTIAL**: The non-biometric credential used to secure the device (i.e., PIN, pattern, or password).
+#### BiometricPolicy Types
+
+BiometricPolicy controls when biometric authentication is required when accessing stored credentials. There are three types of policies available:
+
+**Policy Types**:
+- **BiometricPolicy.Always**: Requires biometric authentication every time credentials are accessed. This is the default policy and provides the highest security level.
+- **BiometricPolicy.Session(timeoutInSeconds)**: Requires biometric authentication only if the specified time (in seconds) has passed since the last successful authentication. Once authenticated, subsequent access within the timeout period will not require re-authentication.
+- **BiometricPolicy.AppLifecycle(timeoutInSeconds = 3600)**: Similar to Session policy, but the session persists for the lifetime of the app process. The default timeout is 1 hour (3600 seconds).
+
+**Examples**:
+
+```kotlin
+// Always require biometric authentication (default)
+val alwaysPolicy = LocalAuthenticationOptions.Builder()
+ .setTitle("Authenticate")
+ .setAuthenticationLevel(AuthenticationLevel.STRONG)
+ .setPolicy(BiometricPolicy.Always)
+ .build()
+
+// Require authentication only once per 5-minute session
+val sessionPolicy = LocalAuthenticationOptions.Builder()
+ .setTitle("Authenticate")
+ .setAuthenticationLevel(AuthenticationLevel.STRONG)
+ .setPolicy(BiometricPolicy.Session(300)) // 5 minutes
+ .build()
+
+// Require authentication once per app lifecycle (1 hour default)
+val appLifecyclePolicy = LocalAuthenticationOptions.Builder()
+ .setTitle("Authenticate")
+ .setAuthenticationLevel(AuthenticationLevel.STRONG)
+ .setPolicy(BiometricPolicy.AppLifecycle()) // Default: 3600 seconds (1 hour)
+ .build()
+```
+
+
+ Using Java
+
+```java
+// Always require biometric authentication (default)
+LocalAuthenticationOptions alwaysPolicy = new LocalAuthenticationOptions.Builder()
+ .setTitle("Authenticate")
+ .setAuthenticationLevel(AuthenticationLevel.STRONG)
+ .setPolicy(BiometricPolicy.Always.INSTANCE)
+ .build();
+
+// Require authentication only once per 5-minute session
+LocalAuthenticationOptions sessionPolicy = new LocalAuthenticationOptions.Builder()
+ .setTitle("Authenticate")
+ .setAuthenticationLevel(AuthenticationLevel.STRONG)
+ .setPolicy(new BiometricPolicy.Session(300)) // 5 minutes
+ .build();
+
+// Require authentication once per app lifecycle (default 1 hour)
+LocalAuthenticationOptions appLifecyclePolicy = new LocalAuthenticationOptions.Builder()
+ .setTitle("Authenticate")
+ .setAuthenticationLevel(AuthenticationLevel.STRONG)
+ .setPolicy(new BiometricPolicy.AppLifecycle()) // Default: 3600 seconds
+ .build();
+```
+
+
+**Managing Biometric Sessions**:
+
+You can manually clear the biometric session to force re-authentication on the next credential access:
+
+```kotlin
+// Clear the biometric session
+secureCredentialsManager.clearBiometricSession()
+
+// Check if the current session is valid
+val isValid = secureCredentialsManager.isBiometricSessionValid()
+```
+
+
### Other Credentials
#### API credentials [EA]
diff --git a/auth0/src/main/java/com/auth0/android/authentication/storage/BiometricPolicy.kt b/auth0/src/main/java/com/auth0/android/authentication/storage/BiometricPolicy.kt
new file mode 100644
index 000000000..577d3366e
--- /dev/null
+++ b/auth0/src/main/java/com/auth0/android/authentication/storage/BiometricPolicy.kt
@@ -0,0 +1,24 @@
+package com.auth0.android.authentication.storage
+
+/**
+ * Defines the policy for when a biometric prompt should be shown when using SecureCredentialsManager.
+ */
+public sealed class BiometricPolicy {
+ /**
+ * Default behavior. A biometric prompt will be shown for every call to getCredentials().
+ */
+ public object Always : BiometricPolicy()
+
+ /**
+ * A biometric prompt will be shown only once within the specified timeout period.
+ * @param timeoutInSeconds The duration for which the session remains valid.
+ */
+ public data class Session(val timeoutInSeconds: Int) : BiometricPolicy()
+
+ /**
+ * A biometric prompt will be shown only once while the app is in the foreground.
+ * The session is invalidated by calling clearBiometricSession() or after the default timeout.
+ * @param timeoutInSeconds The duration for which the session remains valid. Defaults to 3600 seconds (1 hour).
+ */
+ public data class AppLifecycle @JvmOverloads constructor(val timeoutInSeconds: Int = 3600) : BiometricPolicy() // Default 1 hour
+}
diff --git a/auth0/src/main/java/com/auth0/android/authentication/storage/LocalAuthenticationOptions.kt b/auth0/src/main/java/com/auth0/android/authentication/storage/LocalAuthenticationOptions.kt
index 8f47d204e..451600d91 100644
--- a/auth0/src/main/java/com/auth0/android/authentication/storage/LocalAuthenticationOptions.kt
+++ b/auth0/src/main/java/com/auth0/android/authentication/storage/LocalAuthenticationOptions.kt
@@ -9,7 +9,8 @@ public class LocalAuthenticationOptions private constructor(
public val description: String?,
public val authenticationLevel: AuthenticationLevel,
public val enableDeviceCredentialFallback: Boolean,
- public val negativeButtonText: String
+ public val negativeButtonText: String,
+ public val policy: BiometricPolicy
) {
public class Builder(
private var title: String? = null,
@@ -17,7 +18,8 @@ public class LocalAuthenticationOptions private constructor(
private var description: String? = null,
private var authenticationLevel: AuthenticationLevel = AuthenticationLevel.STRONG,
private var enableDeviceCredentialFallback: Boolean = false,
- private var negativeButtonText: String = "Cancel"
+ private var negativeButtonText: String = "Cancel",
+ private var policy: BiometricPolicy = BiometricPolicy.Always
) {
public fun setTitle(title: String): Builder = apply { this.title = title }
@@ -34,13 +36,17 @@ public class LocalAuthenticationOptions private constructor(
public fun setNegativeButtonText(negativeButtonText: String): Builder =
apply { this.negativeButtonText = negativeButtonText }
+ public fun setPolicy(policy: BiometricPolicy): Builder =
+ apply { this.policy = policy }
+
public fun build(): LocalAuthenticationOptions = LocalAuthenticationOptions(
title ?: throw IllegalArgumentException("Title must be provided"),
subtitle,
description,
authenticationLevel,
enableDeviceCredentialFallback,
- negativeButtonText
+ negativeButtonText,
+ policy
)
}
}
@@ -49,4 +55,4 @@ public enum class AuthenticationLevel(public val value: Int) {
STRONG(BiometricManager.Authenticators.BIOMETRIC_STRONG),
WEAK(BiometricManager.Authenticators.BIOMETRIC_WEAK),
DEVICE_CREDENTIAL(BiometricManager.Authenticators.DEVICE_CREDENTIAL);
-}
\ No newline at end of file
+}
diff --git a/auth0/src/main/java/com/auth0/android/authentication/storage/SecureCredentialsManager.kt b/auth0/src/main/java/com/auth0/android/authentication/storage/SecureCredentialsManager.kt
index f1f2c15ac..22c426ec8 100644
--- a/auth0/src/main/java/com/auth0/android/authentication/storage/SecureCredentialsManager.kt
+++ b/auth0/src/main/java/com/auth0/android/authentication/storage/SecureCredentialsManager.kt
@@ -26,6 +26,7 @@ import kotlinx.coroutines.suspendCancellableCoroutine
import java.lang.ref.WeakReference
import java.util.*
import java.util.concurrent.Executor
+import java.util.concurrent.atomic.AtomicLong
import kotlin.collections.component1
import kotlin.collections.component2
import kotlin.coroutines.resume
@@ -44,9 +45,13 @@ public class SecureCredentialsManager @VisibleForTesting(otherwise = VisibleForT
private val fragmentActivity: WeakReference? = null,
private val localAuthenticationOptions: LocalAuthenticationOptions? = null,
private val localAuthenticationManagerFactory: LocalAuthenticationManagerFactory? = null,
+ private val biometricPolicy: BiometricPolicy = BiometricPolicy.Always,
) : BaseCredentialsManager(apiClient, storage, jwtDecoder) {
private val gson: Gson = GsonProvider.gson
+ // Biometric session management
+ private val lastBiometricAuthTime = AtomicLong(NO_SESSION)
+
/**
* Creates a new SecureCredentialsManager to handle Credentials
*
@@ -90,7 +95,8 @@ public class SecureCredentialsManager @VisibleForTesting(otherwise = VisibleForT
auth0.executor,
WeakReference(fragmentActivity),
localAuthenticationOptions,
- DefaultLocalAuthenticationManagerFactory()
+ DefaultLocalAuthenticationManagerFactory(),
+ localAuthenticationOptions?.policy ?: BiometricPolicy.Always
)
/**
@@ -609,6 +615,12 @@ public class SecureCredentialsManager @VisibleForTesting(otherwise = VisibleForT
}
if (fragmentActivity != null && localAuthenticationOptions != null && localAuthenticationManagerFactory != null) {
+ // Check if biometric session is valid based on policy
+ if (isBiometricSessionValid()) {
+ // Session is valid, bypass biometric prompt
+ continueGetCredentials(scope, minTtl, parameters, headers, forceRefresh, callback)
+ return
+ }
fragmentActivity.get()?.let { fragmentActivity ->
startBiometricAuthentication(
@@ -690,6 +702,7 @@ public class SecureCredentialsManager @VisibleForTesting(otherwise = VisibleForT
storage.remove(KEY_EXPIRES_AT)
storage.remove(LEGACY_KEY_CACHE_EXPIRES_AT)
storage.remove(KEY_CAN_REFRESH)
+ clearBiometricSession()
Log.d(TAG, "Credentials were just removed from the storage")
}
@@ -1063,6 +1076,7 @@ public class SecureCredentialsManager @VisibleForTesting(otherwise = VisibleForT
forceRefresh: Boolean, callback: Callback ->
object : Callback {
override fun onSuccess(result: Boolean) {
+ updateBiometricSession()
continueGetCredentials(
scope, minTtl, parameters, headers, forceRefresh,
callback
@@ -1083,6 +1097,7 @@ public class SecureCredentialsManager @VisibleForTesting(otherwise = VisibleForT
callback: Callback ->
object : Callback {
override fun onSuccess(result: Boolean) {
+ updateBiometricSession()
continueGetApiCredentials(
audience, scope, minTtl, parameters, headers,
callback
@@ -1116,6 +1131,42 @@ public class SecureCredentialsManager @VisibleForTesting(otherwise = VisibleForT
saveCredentials(newCredentials)
}
+ /**
+ * Checks if the current biometric session is valid based on the configured policy.
+ */
+ @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
+ internal fun isBiometricSessionValid(): Boolean {
+ val lastAuth = lastBiometricAuthTime.get()
+ if (lastAuth == NO_SESSION) return false // No session exists
+
+ return when (val policy = biometricPolicy) {
+ is BiometricPolicy.Session,
+ is BiometricPolicy.AppLifecycle -> {
+ val timeoutMillis = when (policy) {
+ is BiometricPolicy.Session -> policy.timeoutInSeconds
+ is BiometricPolicy.AppLifecycle -> policy.timeoutInSeconds
+ else -> return false
+ } * 1000L
+ System.currentTimeMillis() - lastAuth < timeoutMillis
+ }
+ is BiometricPolicy.Always -> false
+ }
+ }
+
+ /**
+ * Updates the biometric session timestamp to the current time.
+ */
+ private fun updateBiometricSession() {
+ lastBiometricAuthTime.set(System.currentTimeMillis())
+ }
+
+ /**
+ * Clears the in-memory biometric session timestamp. Can be called from any thread.
+ */
+ public fun clearBiometricSession() {
+ lastBiometricAuthTime.set(NO_SESSION)
+ }
+
internal companion object {
private val TAG = SecureCredentialsManager::class.java.simpleName
@@ -1135,5 +1186,8 @@ public class SecureCredentialsManager @VisibleForTesting(otherwise = VisibleForT
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
internal const val KEY_ALIAS = "com.auth0.key"
+
+ // Using NO_SESSION to represent "no session" (uninitialized state)
+ private const val NO_SESSION = -1L
}
}
\ No newline at end of file
diff --git a/auth0/src/test/java/com/auth0/android/authentication/storage/SecureCredentialsManagerBiometricPolicyTest.kt b/auth0/src/test/java/com/auth0/android/authentication/storage/SecureCredentialsManagerBiometricPolicyTest.kt
new file mode 100644
index 000000000..db478ec2e
--- /dev/null
+++ b/auth0/src/test/java/com/auth0/android/authentication/storage/SecureCredentialsManagerBiometricPolicyTest.kt
@@ -0,0 +1,278 @@
+package com.auth0.android.authentication.storage
+
+import androidx.fragment.app.FragmentActivity
+import com.auth0.android.Auth0
+import com.auth0.android.authentication.AuthenticationAPIClient
+import com.auth0.android.callback.Callback
+import com.auth0.android.result.Credentials
+import com.auth0.android.util.Clock
+import com.nhaarman.mockitokotlin2.*
+import org.junit.After
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.Mock
+import org.mockito.MockitoAnnotations
+import org.robolectric.Robolectric
+import org.robolectric.RobolectricTestRunner
+import java.lang.ref.WeakReference
+import java.util.concurrent.Executor
+
+@RunWith(RobolectricTestRunner::class)
+public class SecureCredentialsManagerBiometricPolicyTest {
+
+ @Mock
+ private lateinit var mockApiClient: AuthenticationAPIClient
+
+ @Mock
+ private lateinit var mockStorage: Storage
+
+ @Mock
+ private lateinit var mockCrypto: CryptoUtil
+
+ @Mock
+ private lateinit var mockJwtDecoder: JWTDecoder
+
+ @Mock
+ private lateinit var mockCredentialsCallback: Callback
+
+ private lateinit var mockActivity: FragmentActivity
+ private lateinit var weakFragmentActivity: WeakReference
+
+ private val testExecutor = Executor { command -> command.run() }
+
+ @Before
+ public fun setUp() {
+ MockitoAnnotations.openMocks(this)
+
+ mockActivity = Robolectric.buildActivity(FragmentActivity::class.java).create().start().resume().get()
+ weakFragmentActivity = WeakReference(mockActivity)
+
+ // Setup default credentials mocking
+ val credentialsJson = """{"access_token":"access_token","id_token":"id_token","refresh_token":"refresh_token","token_type":"Bearer","expires_at":"2023-01-01T00:00:00.000Z"}"""
+ val encryptedCredentials = "dGVzdC1lbmNyeXB0ZWQtY3JlZHM=" // Valid base64
+
+ whenever(mockStorage.retrieveString(SecureCredentialsManager.KEY_CREDENTIALS)).thenReturn(encryptedCredentials)
+ whenever(mockStorage.retrieveLong(SecureCredentialsManager.KEY_EXPIRES_AT)).thenReturn(System.currentTimeMillis() + 100000)
+ whenever(mockCrypto.decrypt(any())).thenReturn(credentialsJson.toByteArray())
+
+ // Mock JWT decoder to return valid claims
+ whenever(mockJwtDecoder.decode(any())).thenReturn(mock())
+ }
+
+ // =========================
+ // Basic Policy Tests
+ // =========================
+
+ @Test
+ public fun `BiometricPolicy Always should be object type`() {
+ val policy1 = BiometricPolicy.Always
+ val policy2 = BiometricPolicy.Always
+
+ assert(policy1 === policy2) // Same instance
+ assert(policy1 == policy2) // Equal
+ }
+
+ @Test
+ public fun `AppLifecycle policy should default to 1 hour timeout`() {
+ val policy = BiometricPolicy.AppLifecycle()
+ assert(policy.timeoutInSeconds == 3600) // 1 hour = 3600 seconds
+ }
+
+ // =========================
+ // LocalAuthenticationOptions Integration Tests
+ // =========================
+
+ @Test
+ public fun `LocalAuthenticationOptions should include biometric policy`() {
+ val policy = BiometricPolicy.Session(600)
+ val options = LocalAuthenticationOptions.Builder()
+ .setTitle("Test Auth")
+ .setPolicy(policy)
+ .build()
+
+ assert(options.policy == policy)
+ }
+
+ @Test
+ public fun `LocalAuthenticationOptions should default to Always policy`() {
+ val options = LocalAuthenticationOptions.Builder()
+ .setTitle("Test Auth")
+ .build()
+
+ assert(options.policy is BiometricPolicy.Always)
+ }
+ // =========================
+ // Session Management Tests without mocking biometric authentication
+ // =========================
+
+ @Test
+ public fun `clearBiometricSession should work without errors`() {
+ val options = LocalAuthenticationOptions.Builder()
+ .setTitle("Test Auth")
+ .setPolicy(BiometricPolicy.Session(300))
+ .build()
+
+ val manager = SecureCredentialsManager(
+ apiClient = mockApiClient,
+ storage = mockStorage,
+ crypto = mockCrypto,
+ jwtDecoder = mockJwtDecoder,
+ serialExecutor = testExecutor,
+ fragmentActivity = null, // No activity to avoid biometric auth
+ localAuthenticationOptions = options,
+ localAuthenticationManagerFactory = null // No factory to avoid biometric auth
+ )
+
+ manager.clearBiometricSession()
+
+ // Session should be invalid initially
+ assert(!manager.isBiometricSessionValid())
+ }
+
+ @Test
+ public fun `isBiometricSessionValid should return false for Always policy`() {
+ val options = LocalAuthenticationOptions.Builder()
+ .setTitle("Test Auth")
+ .setPolicy(BiometricPolicy.Always)
+ .build()
+
+ val manager = SecureCredentialsManager(
+ apiClient = mockApiClient,
+ storage = mockStorage,
+ crypto = mockCrypto,
+ jwtDecoder = mockJwtDecoder,
+ serialExecutor = testExecutor,
+ fragmentActivity = null, // No activity to avoid biometric auth
+ localAuthenticationOptions = options,
+ localAuthenticationManagerFactory = null // No factory to avoid biometric auth
+ )
+
+ // Always policy should never have valid sessions
+ assert(!manager.isBiometricSessionValid())
+ }
+
+ @Test
+ public fun `isBiometricSessionValid should return false for Session policy initially`() {
+ val options = LocalAuthenticationOptions.Builder()
+ .setTitle("Test Auth")
+ .setPolicy(BiometricPolicy.Session(300))
+ .build()
+
+ val manager = SecureCredentialsManager(
+ apiClient = mockApiClient,
+ storage = mockStorage,
+ crypto = mockCrypto,
+ jwtDecoder = mockJwtDecoder,
+ serialExecutor = testExecutor,
+ fragmentActivity = null, // No activity to avoid biometric auth
+ localAuthenticationOptions = options,
+ localAuthenticationManagerFactory = null // No factory to avoid biometric auth
+ )
+
+ // Session should be invalid initially (no authentication has occurred)
+ assert(!manager.isBiometricSessionValid())
+ }
+
+ @Test
+ public fun `isBiometricSessionValid should return false for AppLifecycle policy initially`() {
+ val options = LocalAuthenticationOptions.Builder()
+ .setTitle("Test Auth")
+ .setPolicy(BiometricPolicy.AppLifecycle())
+ .build()
+
+ val manager = SecureCredentialsManager(
+ apiClient = mockApiClient,
+ storage = mockStorage,
+ crypto = mockCrypto,
+ jwtDecoder = mockJwtDecoder,
+ serialExecutor = testExecutor,
+ fragmentActivity = null, // No activity to avoid biometric auth
+ localAuthenticationOptions = options,
+ localAuthenticationManagerFactory = null // No factory to avoid biometric auth
+ )
+
+ // Session should be invalid initially (no authentication has occurred)
+ assert(!manager.isBiometricSessionValid())
+ }
+
+ @Test
+ public fun `session validation should handle concurrent access`() {
+ val options = LocalAuthenticationOptions.Builder()
+ .setTitle("Test Auth")
+ .setPolicy(BiometricPolicy.Session(300))
+ .build()
+
+ val manager = SecureCredentialsManager(
+ apiClient = mockApiClient,
+ storage = mockStorage,
+ crypto = mockCrypto,
+ jwtDecoder = mockJwtDecoder,
+ serialExecutor = testExecutor,
+ fragmentActivity = null, // No activity to avoid biometric auth
+ localAuthenticationOptions = options,
+ localAuthenticationManagerFactory = null // No factory to avoid biometric auth
+ )
+
+ // Multiple session validity checks (simulating concurrent access)
+ repeat(10) {
+ manager.isBiometricSessionValid()
+ }
+
+ // Should not crash and should be false (no session established)
+ assert(!manager.isBiometricSessionValid())
+ }
+
+ @Test
+ public fun `clearBiometricSession should be thread safe`() {
+ val options = LocalAuthenticationOptions.Builder()
+ .setTitle("Test Auth")
+ .setPolicy(BiometricPolicy.Session(300))
+ .build()
+
+ val manager = SecureCredentialsManager(
+ apiClient = mockApiClient,
+ storage = mockStorage,
+ crypto = mockCrypto,
+ jwtDecoder = mockJwtDecoder,
+ serialExecutor = testExecutor,
+ fragmentActivity = null, // No activity to avoid biometric auth
+ localAuthenticationOptions = options,
+ localAuthenticationManagerFactory = null // No factory to avoid biometric auth
+ )
+
+ // Clear from multiple threads (simulated with multiple calls)
+ repeat(10) {
+ manager.clearBiometricSession()
+ }
+
+ // session should be invalid
+ assert(!manager.isBiometricSessionValid())
+ }
+
+ @Test
+ public fun `clearCredentials should also clear biometric session`() {
+ val options = LocalAuthenticationOptions.Builder()
+ .setTitle("Test Auth")
+ .setPolicy(BiometricPolicy.Session(300))
+ .build()
+
+ val manager = SecureCredentialsManager(
+ apiClient = mockApiClient,
+ storage = mockStorage,
+ crypto = mockCrypto,
+ jwtDecoder = mockJwtDecoder,
+ serialExecutor = testExecutor,
+ fragmentActivity = null, // No activity to avoid biometric auth
+ localAuthenticationOptions = options,
+ localAuthenticationManagerFactory = null // No factory to avoid biometric auth
+ )
+
+ // Clear credentials
+ manager.clearCredentials()
+ verify(mockStorage, atLeastOnce()).remove(any())
+
+ // Session should be invalid
+ assert(!manager.isBiometricSessionValid())
+ }
+}