Skip to content

Commit e937921

Browse files
feat: add configurable biometric authentication policies for SecureCredentialsManager (#867)
2 parents 88fc569 + edd702e commit e937921

File tree

5 files changed

+444
-5
lines changed

5 files changed

+444
-5
lines changed

EXAMPLES.md

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1393,6 +1393,7 @@ val localAuthenticationOptions =
13931393
LocalAuthenticationOptions.Builder().setTitle("Authenticate").setDescription("Accessing Credentials")
13941394
.setAuthenticationLevel(AuthenticationLevel.STRONG).setNegativeButtonText("Cancel")
13951395
.setDeviceCredentialFallback(true)
1396+
.setPolicy(BiometricPolicy.Session(300)) // Optional: Use session-based policy (5 minutes)
13961397
.build()
13971398
val storage = SharedPreferencesStorage(this)
13981399
val manager = SecureCredentialsManager(
@@ -1409,6 +1410,7 @@ LocalAuthenticationOptions localAuthenticationOptions =
14091410
new LocalAuthenticationOptions.Builder().setTitle("Authenticate").setDescription("Accessing Credentials")
14101411
.setAuthenticationLevel(AuthenticationLevel.STRONG).setNegativeButtonText("Cancel")
14111412
.setDeviceCredentialFallback(true)
1413+
.setPolicy(new BiometricPolicy.Session(300)) // Optional: Use session-based policy (5 minutes)
14121414
.build();
14131415
Storage storage = new SharedPreferencesStorage(context);
14141416
SecureCredentialsManager secureCredentialsManager = new SecureCredentialsManager(
@@ -1433,6 +1435,7 @@ On Android API 28 and 29, specifying **STRONG** as the authentication level alon
14331435
- **setAuthenticationLevel(authenticationLevel: AuthenticationLevel): Builder** - Sets the authentication level, more on this can be found [here](#authenticationlevel-enum-values)
14341436
- **setDeviceCredentialFallback(enableDeviceCredentialFallback: Boolean): Builder** - Enables/disables device credential fallback.
14351437
- **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`.
1438+
- **setPolicy(policy: BiometricPolicy): Builder** - Sets the biometric policy that controls when biometric authentication is required. See [BiometricPolicy Types](#biometricpolicy-types) for more details.
14361439
- **build(): LocalAuthenticationOptions** - Constructs the LocalAuthenticationOptions instance.
14371440

14381441

@@ -1446,6 +1449,80 @@ AuthenticationLevel is an enum that defines the different levels of authenticati
14461449
- **DEVICE_CREDENTIAL**: The non-biometric credential used to secure the device (i.e., PIN, pattern, or password).
14471450

14481451

1452+
#### BiometricPolicy Types
1453+
1454+
BiometricPolicy controls when biometric authentication is required when accessing stored credentials. There are three types of policies available:
1455+
1456+
**Policy Types**:
1457+
- **BiometricPolicy.Always**: Requires biometric authentication every time credentials are accessed. This is the default policy and provides the highest security level.
1458+
- **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.
1459+
- **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).
1460+
1461+
**Examples**:
1462+
1463+
```kotlin
1464+
// Always require biometric authentication (default)
1465+
val alwaysPolicy = LocalAuthenticationOptions.Builder()
1466+
.setTitle("Authenticate")
1467+
.setAuthenticationLevel(AuthenticationLevel.STRONG)
1468+
.setPolicy(BiometricPolicy.Always)
1469+
.build()
1470+
1471+
// Require authentication only once per 5-minute session
1472+
val sessionPolicy = LocalAuthenticationOptions.Builder()
1473+
.setTitle("Authenticate")
1474+
.setAuthenticationLevel(AuthenticationLevel.STRONG)
1475+
.setPolicy(BiometricPolicy.Session(300)) // 5 minutes
1476+
.build()
1477+
1478+
// Require authentication once per app lifecycle (1 hour default)
1479+
val appLifecyclePolicy = LocalAuthenticationOptions.Builder()
1480+
.setTitle("Authenticate")
1481+
.setAuthenticationLevel(AuthenticationLevel.STRONG)
1482+
.setPolicy(BiometricPolicy.AppLifecycle()) // Default: 3600 seconds (1 hour)
1483+
.build()
1484+
```
1485+
1486+
<details>
1487+
<summary>Using Java</summary>
1488+
1489+
```java
1490+
// Always require biometric authentication (default)
1491+
LocalAuthenticationOptions alwaysPolicy = new LocalAuthenticationOptions.Builder()
1492+
.setTitle("Authenticate")
1493+
.setAuthenticationLevel(AuthenticationLevel.STRONG)
1494+
.setPolicy(BiometricPolicy.Always.INSTANCE)
1495+
.build();
1496+
1497+
// Require authentication only once per 5-minute session
1498+
LocalAuthenticationOptions sessionPolicy = new LocalAuthenticationOptions.Builder()
1499+
.setTitle("Authenticate")
1500+
.setAuthenticationLevel(AuthenticationLevel.STRONG)
1501+
.setPolicy(new BiometricPolicy.Session(300)) // 5 minutes
1502+
.build();
1503+
1504+
// Require authentication once per app lifecycle (default 1 hour)
1505+
LocalAuthenticationOptions appLifecyclePolicy = new LocalAuthenticationOptions.Builder()
1506+
.setTitle("Authenticate")
1507+
.setAuthenticationLevel(AuthenticationLevel.STRONG)
1508+
.setPolicy(new BiometricPolicy.AppLifecycle()) // Default: 3600 seconds
1509+
.build();
1510+
```
1511+
</details>
1512+
1513+
**Managing Biometric Sessions**:
1514+
1515+
You can manually clear the biometric session to force re-authentication on the next credential access:
1516+
1517+
```kotlin
1518+
// Clear the biometric session
1519+
secureCredentialsManager.clearBiometricSession()
1520+
1521+
// Check if the current session is valid
1522+
val isValid = secureCredentialsManager.isBiometricSessionValid()
1523+
```
1524+
1525+
14491526
### Other Credentials
14501527

14511528
#### API credentials [EA]
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
package com.auth0.android.authentication.storage
2+
3+
/**
4+
* Defines the policy for when a biometric prompt should be shown when using SecureCredentialsManager.
5+
*/
6+
public sealed class BiometricPolicy {
7+
/**
8+
* Default behavior. A biometric prompt will be shown for every call to getCredentials().
9+
*/
10+
public object Always : BiometricPolicy()
11+
12+
/**
13+
* A biometric prompt will be shown only once within the specified timeout period.
14+
* @param timeoutInSeconds The duration for which the session remains valid.
15+
*/
16+
public data class Session(val timeoutInSeconds: Int) : BiometricPolicy()
17+
18+
/**
19+
* A biometric prompt will be shown only once while the app is in the foreground.
20+
* The session is invalidated by calling clearBiometricSession() or after the default timeout.
21+
* @param timeoutInSeconds The duration for which the session remains valid. Defaults to 3600 seconds (1 hour).
22+
*/
23+
public data class AppLifecycle @JvmOverloads constructor(val timeoutInSeconds: Int = 3600) : BiometricPolicy() // Default 1 hour
24+
}

auth0/src/main/java/com/auth0/android/authentication/storage/LocalAuthenticationOptions.kt

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,15 +9,17 @@ public class LocalAuthenticationOptions private constructor(
99
public val description: String?,
1010
public val authenticationLevel: AuthenticationLevel,
1111
public val enableDeviceCredentialFallback: Boolean,
12-
public val negativeButtonText: String
12+
public val negativeButtonText: String,
13+
public val policy: BiometricPolicy
1314
) {
1415
public class Builder(
1516
private var title: String? = null,
1617
private var subtitle: String? = null,
1718
private var description: String? = null,
1819
private var authenticationLevel: AuthenticationLevel = AuthenticationLevel.STRONG,
1920
private var enableDeviceCredentialFallback: Boolean = false,
20-
private var negativeButtonText: String = "Cancel"
21+
private var negativeButtonText: String = "Cancel",
22+
private var policy: BiometricPolicy = BiometricPolicy.Always
2123
) {
2224

2325
public fun setTitle(title: String): Builder = apply { this.title = title }
@@ -34,13 +36,17 @@ public class LocalAuthenticationOptions private constructor(
3436
public fun setNegativeButtonText(negativeButtonText: String): Builder =
3537
apply { this.negativeButtonText = negativeButtonText }
3638

39+
public fun setPolicy(policy: BiometricPolicy): Builder =
40+
apply { this.policy = policy }
41+
3742
public fun build(): LocalAuthenticationOptions = LocalAuthenticationOptions(
3843
title ?: throw IllegalArgumentException("Title must be provided"),
3944
subtitle,
4045
description,
4146
authenticationLevel,
4247
enableDeviceCredentialFallback,
43-
negativeButtonText
48+
negativeButtonText,
49+
policy
4450
)
4551
}
4652
}
@@ -49,4 +55,4 @@ public enum class AuthenticationLevel(public val value: Int) {
4955
STRONG(BiometricManager.Authenticators.BIOMETRIC_STRONG),
5056
WEAK(BiometricManager.Authenticators.BIOMETRIC_WEAK),
5157
DEVICE_CREDENTIAL(BiometricManager.Authenticators.DEVICE_CREDENTIAL);
52-
}
58+
}

auth0/src/main/java/com/auth0/android/authentication/storage/SecureCredentialsManager.kt

Lines changed: 55 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import kotlinx.coroutines.suspendCancellableCoroutine
2626
import java.lang.ref.WeakReference
2727
import java.util.*
2828
import java.util.concurrent.Executor
29+
import java.util.concurrent.atomic.AtomicLong
2930
import kotlin.collections.component1
3031
import kotlin.collections.component2
3132
import kotlin.coroutines.resume
@@ -44,9 +45,13 @@ public class SecureCredentialsManager @VisibleForTesting(otherwise = VisibleForT
4445
private val fragmentActivity: WeakReference<FragmentActivity>? = null,
4546
private val localAuthenticationOptions: LocalAuthenticationOptions? = null,
4647
private val localAuthenticationManagerFactory: LocalAuthenticationManagerFactory? = null,
48+
private val biometricPolicy: BiometricPolicy = BiometricPolicy.Always,
4749
) : BaseCredentialsManager(apiClient, storage, jwtDecoder) {
4850
private val gson: Gson = GsonProvider.gson
4951

52+
// Biometric session management
53+
private val lastBiometricAuthTime = AtomicLong(NO_SESSION)
54+
5055
/**
5156
* Creates a new SecureCredentialsManager to handle Credentials
5257
*
@@ -90,7 +95,8 @@ public class SecureCredentialsManager @VisibleForTesting(otherwise = VisibleForT
9095
auth0.executor,
9196
WeakReference(fragmentActivity),
9297
localAuthenticationOptions,
93-
DefaultLocalAuthenticationManagerFactory()
98+
DefaultLocalAuthenticationManagerFactory(),
99+
localAuthenticationOptions?.policy ?: BiometricPolicy.Always
94100
)
95101

96102
/**
@@ -609,6 +615,12 @@ public class SecureCredentialsManager @VisibleForTesting(otherwise = VisibleForT
609615
}
610616

611617
if (fragmentActivity != null && localAuthenticationOptions != null && localAuthenticationManagerFactory != null) {
618+
// Check if biometric session is valid based on policy
619+
if (isBiometricSessionValid()) {
620+
// Session is valid, bypass biometric prompt
621+
continueGetCredentials(scope, minTtl, parameters, headers, forceRefresh, callback)
622+
return
623+
}
612624

613625
fragmentActivity.get()?.let { fragmentActivity ->
614626
startBiometricAuthentication(
@@ -690,6 +702,7 @@ public class SecureCredentialsManager @VisibleForTesting(otherwise = VisibleForT
690702
storage.remove(KEY_EXPIRES_AT)
691703
storage.remove(LEGACY_KEY_CACHE_EXPIRES_AT)
692704
storage.remove(KEY_CAN_REFRESH)
705+
clearBiometricSession()
693706
Log.d(TAG, "Credentials were just removed from the storage")
694707
}
695708

@@ -1063,6 +1076,7 @@ public class SecureCredentialsManager @VisibleForTesting(otherwise = VisibleForT
10631076
forceRefresh: Boolean, callback: Callback<Credentials, CredentialsManagerException> ->
10641077
object : Callback<Boolean, CredentialsManagerException> {
10651078
override fun onSuccess(result: Boolean) {
1079+
updateBiometricSession()
10661080
continueGetCredentials(
10671081
scope, minTtl, parameters, headers, forceRefresh,
10681082
callback
@@ -1083,6 +1097,7 @@ public class SecureCredentialsManager @VisibleForTesting(otherwise = VisibleForT
10831097
callback: Callback<APICredentials, CredentialsManagerException> ->
10841098
object : Callback<Boolean, CredentialsManagerException> {
10851099
override fun onSuccess(result: Boolean) {
1100+
updateBiometricSession()
10861101
continueGetApiCredentials(
10871102
audience, scope, minTtl, parameters, headers,
10881103
callback
@@ -1116,6 +1131,42 @@ public class SecureCredentialsManager @VisibleForTesting(otherwise = VisibleForT
11161131
saveCredentials(newCredentials)
11171132
}
11181133

1134+
/**
1135+
* Checks if the current biometric session is valid based on the configured policy.
1136+
*/
1137+
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
1138+
internal fun isBiometricSessionValid(): Boolean {
1139+
val lastAuth = lastBiometricAuthTime.get()
1140+
if (lastAuth == NO_SESSION) return false // No session exists
1141+
1142+
return when (val policy = biometricPolicy) {
1143+
is BiometricPolicy.Session,
1144+
is BiometricPolicy.AppLifecycle -> {
1145+
val timeoutMillis = when (policy) {
1146+
is BiometricPolicy.Session -> policy.timeoutInSeconds
1147+
is BiometricPolicy.AppLifecycle -> policy.timeoutInSeconds
1148+
else -> return false
1149+
} * 1000L
1150+
System.currentTimeMillis() - lastAuth < timeoutMillis
1151+
}
1152+
is BiometricPolicy.Always -> false
1153+
}
1154+
}
1155+
1156+
/**
1157+
* Updates the biometric session timestamp to the current time.
1158+
*/
1159+
private fun updateBiometricSession() {
1160+
lastBiometricAuthTime.set(System.currentTimeMillis())
1161+
}
1162+
1163+
/**
1164+
* Clears the in-memory biometric session timestamp. Can be called from any thread.
1165+
*/
1166+
public fun clearBiometricSession() {
1167+
lastBiometricAuthTime.set(NO_SESSION)
1168+
}
1169+
11191170
internal companion object {
11201171
private val TAG = SecureCredentialsManager::class.java.simpleName
11211172

@@ -1135,5 +1186,8 @@ public class SecureCredentialsManager @VisibleForTesting(otherwise = VisibleForT
11351186

11361187
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
11371188
internal const val KEY_ALIAS = "com.auth0.key"
1189+
1190+
// Using NO_SESSION to represent "no session" (uninitialized state)
1191+
private const val NO_SESSION = -1L
11381192
}
11391193
}

0 commit comments

Comments
 (0)