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
79 changes: 79 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,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()
```

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

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


### 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(val timeoutInSeconds: Int = 3600) : BiometricPolicy() // Default 1 hour
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This needs the @jvmoverloads annotation to work with seamlessly java

}
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,6 +45,7 @@ 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

Expand Down Expand Up @@ -90,7 +92,8 @@ public class SecureCredentialsManager @VisibleForTesting(otherwise = VisibleForT
auth0.executor,
WeakReference(fragmentActivity),
localAuthenticationOptions,
DefaultLocalAuthenticationManagerFactory()
DefaultLocalAuthenticationManagerFactory(),
localAuthenticationOptions?.policy ?: BiometricPolicy.Always
)

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

Expand Down Expand Up @@ -1063,6 +1073,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 +1094,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 +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
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The two cases can be combined

return when (biometricPolicy) {
            is BiometricPolicy.Session ,
            is BiometricPolicy.AppLifecycle -> {
                val timeoutMillis = biometricPolicy.timeoutInSeconds * 1000L
                System.currentTimeMillis() - lastAuth < timeoutMillis
            }
            is BiometricPolicy.Always -> false
            }
    ```        

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 +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)
Comment on lines +1188 to +1189
Copy link

Copilot AI Sep 9, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The lastBiometricAuthTime is declared as a companion object property, making it shared across all instances of SecureCredentialsManager. This creates a potential issue where multiple manager instances would interfere with each other's session state. Consider making this an instance variable instead of a companion object property.

Copilot uses AI. Check for mistakes.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agree, why does lastBiometricAuthTime defined as companion object property. This needs to be class property

}
}
Loading