diff --git a/EXAMPLES.md b/EXAMPLES.md index f5b99c44..3049cf2d 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,82 @@ 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") + .setDescription("Accessing Credentials") + .setAuthenticationLevel(AuthenticationLevel.STRONG) + .setPolicy(BiometricPolicy.Always) + .build() + +// Require authentication only once per 5-minute session +val sessionPolicy = LocalAuthenticationOptions.Builder() + .setTitle("Authenticate") + .setDescription("Accessing Credentials") + .setAuthenticationLevel(AuthenticationLevel.STRONG) + .setPolicy(BiometricPolicy.Session(300)) // 5 minutes + .build() + +// Require authentication once per app lifecycle (1 hour timeout) +val appLifecyclePolicy = LocalAuthenticationOptions.Builder() + .setTitle("Authenticate") + .setDescription("Accessing Credentials") + .setAuthenticationLevel(AuthenticationLevel.STRONG) + .setPolicy(BiometricPolicy.AppLifecycle()) // Uses default 1 hour timeout + .build() + +// Custom app lifecycle timeout (2 hours) +val customAppLifecyclePolicy = LocalAuthenticationOptions.Builder() + .setTitle("Authenticate") + .setDescription("Accessing Credentials") + .setAuthenticationLevel(AuthenticationLevel.STRONG) + .setPolicy(BiometricPolicy.AppLifecycle(7200)) // 2 hours + .build() +``` + +
+ Using Java + +```java +// Always require biometric authentication (default) +LocalAuthenticationOptions alwaysPolicy = new LocalAuthenticationOptions.Builder() + .setTitle("Authenticate") + .setDescription("Accessing Credentials") + .setAuthenticationLevel(AuthenticationLevel.STRONG) + .setPolicy(BiometricPolicy.Always.INSTANCE) + .build(); + +// Require authentication only once per 5-minute session +LocalAuthenticationOptions sessionPolicy = new LocalAuthenticationOptions.Builder() + .setTitle("Authenticate") + .setDescription("Accessing Credentials") + .setAuthenticationLevel(AuthenticationLevel.STRONG) + .setPolicy(new BiometricPolicy.Session(300)) // 5 minutes + .build(); + +// Require authentication once per app lifecycle (1 hour timeout) +LocalAuthenticationOptions appLifecyclePolicy = new LocalAuthenticationOptions.Builder() + .setTitle("Authenticate") + .setDescription("Accessing Credentials") + .setAuthenticationLevel(AuthenticationLevel.STRONG) + .setPolicy(new BiometricPolicy.AppLifecycle()) // Uses default 1 hour timeout + .build(); +``` +
+ + ### 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 00000000..4a9776ba --- /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(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 8f47d204..451600d9 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 f1f2c15a..c948c49e 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,6 +45,7 @@ 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 @@ -90,7 +92,8 @@ public class SecureCredentialsManager @VisibleForTesting(otherwise = VisibleForT auth0.executor, WeakReference(fragmentActivity), localAuthenticationOptions, - DefaultLocalAuthenticationManagerFactory() + DefaultLocalAuthenticationManagerFactory(), + localAuthenticationOptions?.policy ?: BiometricPolicy.Always ) /** @@ -609,6 +612,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 +699,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 +1073,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 +1094,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 +1128,41 @@ 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 (biometricPolicy) { + is BiometricPolicy.Session -> { + val timeoutMillis = biometricPolicy.timeoutInSeconds * 1000L + System.currentTimeMillis() - lastAuth < timeoutMillis + } + is BiometricPolicy.AppLifecycle -> { + val timeoutMillis = biometricPolicy.timeoutInSeconds * 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 +1182,10 @@ public class SecureCredentialsManager @VisibleForTesting(otherwise = VisibleForT @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) internal const val KEY_ALIAS = "com.auth0.key" + + // Biometric session management + // Using NO_SESSION to represent "no session" (uninitialized state) + private const val NO_SESSION = -1L + private val lastBiometricAuthTime = AtomicLong(NO_SESSION) } } \ 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 00000000..1e10f532 --- /dev/null +++ b/auth0/src/test/java/com/auth0/android/authentication/storage/SecureCredentialsManagerBiometricPolicyTest.kt @@ -0,0 +1,301 @@ +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 `BiometricPolicy Session should be data class with timeout parameter`() { + val policy1 = BiometricPolicy.Session(300) + val policy2 = BiometricPolicy.Session(300) + val policy3 = BiometricPolicy.Session(600) + + assert(policy1 == policy2) // Same timeout, should be equal + assert(policy1 != policy3) // Different timeout, should not be equal + assert(policy1.timeoutInSeconds == 300) + } + + @Test + public fun `BiometricPolicy AppLifecycle should be data class with default timeout`() { + val policy1 = BiometricPolicy.AppLifecycle() + val policy2 = BiometricPolicy.AppLifecycle() + val policy3 = BiometricPolicy.AppLifecycle(7200) + + assert(policy1 == policy2) // Same default timeout, should be equal + assert(policy1 != policy3) // Different timeout, should not be equal + assert(policy1.timeoutInSeconds == 3600) // Default 1 hour + assert(policy3.timeoutInSeconds == 7200) // Custom 2 hours + } + + @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()) + } +}