Skip to content
Open
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
77 changes: 77 additions & 0 deletions EXAMPLES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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(
Expand All @@ -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.


Expand All @@ -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()
```

<details>
<summary>Using Java</summary>

```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();
```
</details>

**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]
Expand Down
Original file line number Diff line number Diff line change
@@ -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
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,15 +9,17 @@ 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,
private var subtitle: String? = null,
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 }
Expand All @@ -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
)
}
}
Expand All @@ -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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -44,9 +45,13 @@ public class SecureCredentialsManager @VisibleForTesting(otherwise = VisibleForT
private val fragmentActivity: WeakReference<FragmentActivity>? = 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
*
Expand Down Expand Up @@ -90,7 +95,8 @@ public class SecureCredentialsManager @VisibleForTesting(otherwise = VisibleForT
auth0.executor,
WeakReference(fragmentActivity),
localAuthenticationOptions,
DefaultLocalAuthenticationManagerFactory()
DefaultLocalAuthenticationManagerFactory(),
localAuthenticationOptions?.policy ?: BiometricPolicy.Always
)

/**
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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")
}

Expand Down Expand Up @@ -1063,6 +1076,7 @@ public class SecureCredentialsManager @VisibleForTesting(otherwise = VisibleForT
forceRefresh: Boolean, callback: Callback<Credentials, CredentialsManagerException> ->
object : Callback<Boolean, CredentialsManagerException> {
override fun onSuccess(result: Boolean) {
updateBiometricSession()
continueGetCredentials(
scope, minTtl, parameters, headers, forceRefresh,
callback
Expand All @@ -1083,6 +1097,7 @@ public class SecureCredentialsManager @VisibleForTesting(otherwise = VisibleForT
callback: Callback<APICredentials, CredentialsManagerException> ->
object : Callback<Boolean, CredentialsManagerException> {
override fun onSuccess(result: Boolean) {
updateBiometricSession()
continueGetApiCredentials(
audience, scope, minTtl, parameters, headers,
callback
Expand Down Expand Up @@ -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

Expand All @@ -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
}
}
Loading