Skip to content
Draft
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
57 changes: 50 additions & 7 deletions EXAMPLES.md
Original file line number Diff line number Diff line change
Expand Up @@ -280,6 +280,21 @@ if (DPoP.isNonceRequiredError(response)) {
}
```

When using DPoP with `CredentialsManager` or `SecureCredentialsManager`, the `AuthenticationAPIClient` passed to the credentials manager **must** also have DPoP enabled. Otherwise, token refresh requests will be sent without the DPoP proof and the SDK will throw a `CredentialsManagerException.DPOP_NOT_CONFIGURED` error.

```kotlin

val auth0 = Auth0.getInstance("YOUR_CLIENT_ID", "YOUR_DOMAIN")
val apiClient = AuthenticationAPIClient(auth0).useDPoP(context) // DPoP enabled
val storage = SharedPreferencesStorage(context)
val manager = CredentialsManager(apiClient, storage)

WebAuthProvider
.useDPoP()
.login(auth0)
.start(context, callback)
```

On logout, you should call `DPoP.clearKeyPair()` to delete the user's key pair from the Keychain.

```kotlin
Expand All @@ -293,7 +308,7 @@ WebAuthProvider.logout(account)

})
```
> [!NOTE]
> [!NOTE]
> DPoP is supported only on Android version 6.0 (API level 23) and above. Trying to use DPoP in any older versions will result in an exception.

## Authentication API
Expand Down Expand Up @@ -1662,11 +1677,21 @@ val auth0 = Auth0.getInstance("YOUR_CLIENT_ID", "YOUR_DOMAIN")
val apiClient = AuthenticationAPIClient(auth0).useDPoP(this)
val storage = SharedPreferencesStorage(this)
val manager = SecureCredentialsManager(apiClient, this, auth0, storage)
```

Similarly, for `CredentialsManager`:

```kotlin
val auth0 = Auth0.getInstance("YOUR_CLIENT_ID", "YOUR_DOMAIN")
val apiClient = AuthenticationAPIClient(auth0).useDPoP(this)
val storage = SharedPreferencesStorage(this)
val manager = CredentialsManager(apiClient, storage)
```

> [!IMPORTANT]
> When credentials are DPoP-bound, the SDK validates the DPoP key state before each token refresh. If the DPoP key pair is lost, the SDK will throw `CredentialsManagerException.DPOP_KEY_MISSING` and the user must re-authenticate. If the key pair has changed since the credentials were saved, the SDK will throw `CredentialsManagerException.DPOP_KEY_MISMATCH`. If the `AuthenticationAPIClient` was not configured with `useDPoP()`, the SDK will throw `CredentialsManagerException.DPOP_NOT_CONFIGURED`.

> [!NOTE]
> [!NOTE]
> DPoP is supported only on Android version 6.0 (API level 23) and above. Trying to use DPoP in any older versions will result in an exception.


Expand Down Expand Up @@ -2587,24 +2612,42 @@ In the event that something happened while trying to save or retrieve the creden
- Tokens have expired but no `refresh_token` is available to perform a refresh credentials request.
- Device's Lock Screen security settings have changed (e.g. the PIN code was changed). Even when `hasCredentials` returns true, the encryption keys will be deemed invalid and until `saveCredentials` is called again it won't be possible to decrypt any previously existing content, since they keys used back then are not the same as the new ones.
- Device is not compatible with some of the algorithms required by the `SecureCredentialsManager` class. This is considered a catastrophic event and might happen when the OEM has modified the Android ROM removing some of the officially included algorithms. Nevertheless, it can be checked in the exception instance itself by calling `isDeviceIncompatible`. By doing so you can decide the fallback for storing the credentials, such as using the regular `CredentialsManager`.
- **DPoP key pair lost** — The DPoP key pair is no longer available in the Android KeyStore. The stored credentials are cleared and re-authentication is required.
- **DPoP key pair mismatch** — The DPoP key pair exists but is different from the one used when the credentials were saved. The stored credentials are cleared and re-authentication is required.
- **DPoP not configured** — The stored credentials are DPoP-bound but the `AuthenticationAPIClient` used by the credentials manager was not configured with `useDPoP(context)`. The developer needs to call `AuthenticationAPIClient(auth0).useDPoP(context)` and pass the configured client to the credentials manager.

You can access the `code` property of the `CredentialsManagerException` to understand why the operation with `CredentialsManager` has failed and the `message` property of the `CredentialsManagerException` would give you a description of the exception.
You can access the `code` property of the `CredentialsManagerException` to understand why the operation with `CredentialsManager` has failed and the `message` property of the `CredentialsManagerException` would give you a description of the exception.

Starting from version `3.0.0` you can even pass the exception to a `when` expression and handle the exception accordingly in your app's logic as shown in the below code snippet:
Starting from version `3.0.0` you can even pass the exception to a `when` expression and handle the exception accordingly in your app's logic as shown in the below code snippet:

```kotlin
when(credentialsManagerException) {
CredentialsManagerException.NO_CREDENTIALS - > {
CredentialsManagerException.NO_CREDENTIALS -> {
// handle no credentials scenario
}

CredentialsManagerException.NO_REFRESH_TOKEN - > {
CredentialsManagerException.NO_REFRESH_TOKEN -> {
// handle no refresh token scenario
}

CredentialsManagerException.STORE_FAILED - > {
CredentialsManagerException.STORE_FAILED -> {
// handle store failed scenario
}

CredentialsManagerException.DPOP_KEY_MISSING -> {
// DPoP key was lost
// Clear local state and prompt user to re-authenticate
}

CredentialsManagerException.DPOP_KEY_MISMATCH -> {
// DPoP key exists but doesn't match the one used at login (key rotation)
// Clear local state and prompt user to re-authenticate
}

CredentialsManagerException.DPOP_NOT_CONFIGURED -> {
// Developer forgot to call useDPoP() on the AuthenticationAPIClient
// passed to the credentials manager. Fix the client configuration.
}
// ... similarly for other error codes
}
```
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,13 @@ public class AuthenticationAPIClient @VisibleForTesting(otherwise = VisibleForTe

private var dPoP: DPoP? = null

/**
* Returns whether DPoP (Demonstrating Proof of Possession) is enabled on this client.
* DPoP is enabled by calling [useDPoP].
*/
public val isDPoPEnabled: Boolean
get() = dPoP != null

/**
* Creates a new API client instance providing Auth0 account info.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import android.util.Log
import androidx.annotation.VisibleForTesting
import com.auth0.android.authentication.AuthenticationAPIClient
import com.auth0.android.callback.Callback
import com.auth0.android.dpop.DPoPException
import com.auth0.android.dpop.DPoPUtil
import com.auth0.android.result.APICredentials
import com.auth0.android.result.Credentials
import com.auth0.android.result.SSOCredentials
Expand All @@ -20,6 +22,14 @@ public abstract class BaseCredentialsManager internal constructor(
protected val storage: Storage,
private val jwtDecoder: JWTDecoder
) {

internal companion object {
internal const val KEY_DPOP_THUMBPRINT = "com.auth0.dpop_key_thumbprint"

@VisibleForTesting(otherwise = VisibleForTesting.PACKAGE_PRIVATE)
internal const val KEY_TOKEN_TYPE = "com.auth0.token_type"
}

private var _clock: Clock = ClockImpl()

/**
Expand Down Expand Up @@ -155,6 +165,92 @@ public abstract class BaseCredentialsManager internal constructor(
internal val currentTimeInMillis: Long
get() = _clock.getCurrentTimeMillis()

/**
* Stores the DPoP key thumbprint if DPoP was used for this credential set.
* Uses a dual strategy to store the thumbprint:
* - credentials.type == "DPoP" when server confirms DPoP but client lacks useDPoP()
* - isDPoPEnabled catches the case where client used DPoP, server returned token_type: "Bearer"
*/
protected fun saveDPoPThumbprint(credentials: Credentials) {
val dpopUsed = credentials.type.equals("DPoP", ignoreCase = true)
|| authenticationClient.isDPoPEnabled

if (!dpopUsed) {
storage.remove(KEY_DPOP_THUMBPRINT)
return
}

val thumbprint = try {
if (DPoPUtil.hasKeyPair()) DPoPUtil.getPublicKeyJWK() else null
} catch (e: DPoPException) {
Log.w(this::class.java.simpleName, "Failed to fetch DPoP key thumbprint", e)
null
}

if (thumbprint != null) {
storage.store(KEY_DPOP_THUMBPRINT, thumbprint)
} else {
storage.remove(KEY_DPOP_THUMBPRINT)
}
}

/**
* Validates DPoP key/token alignment before attempting a refresh.
*
* Uses two signals to detect DPoP-bound credentials:
* - tokenType == "DPoP"
* - KEY_DPOP_THUMBPRINT exists
*
* @param tokenType the token_type value from storage (or decrypted credentials for migration)
* @return null if validation passes, or a CredentialsManagerException if it fails
*/
protected fun validateDPoPState(tokenType: String?): CredentialsManagerException? {
val storedThumbprint = storage.retrieveString(KEY_DPOP_THUMBPRINT)
val isDPoPBound = (tokenType?.equals("DPoP", ignoreCase = true) == true)
|| (storedThumbprint != null)
if (!isDPoPBound) return null

// Check 1: Does the DPoP key still exist in KeyStore?
val hasKey = try {
DPoPUtil.hasKeyPair()
} catch (e: DPoPException) {
Log.e(this::class.java.simpleName, "Failed to check DPoP key existence", e)
false
}
if (!hasKey) {
Log.w(this::class.java.simpleName, "DPoP key missing from KeyStore. Clearing stale credentials.")
clearCredentials()
return CredentialsManagerException(CredentialsManagerException.Code.DPOP_KEY_MISSING)
}

// Check 2: Is the AuthenticationAPIClient configured with DPoP?
if (!authenticationClient.isDPoPEnabled) {
return CredentialsManagerException(CredentialsManagerException.Code.DPOP_NOT_CONFIGURED)
}

// Check 3: Does the current key match the one used when credentials were saved?
val currentThumbprint = try {
DPoPUtil.getPublicKeyJWK()
} catch (e: DPoPException) {
Log.e(this::class.java.simpleName, "Failed to read DPoP key thumbprint", e)
null
}

if (storedThumbprint != null) {
if (currentThumbprint != storedThumbprint) {
Log.w(this::class.java.simpleName, "DPoP key thumbprint mismatch. The key pair has changed since credentials were saved. Clearing stale credentials.")
clearCredentials()
return CredentialsManagerException(CredentialsManagerException.Code.DPOP_KEY_MISMATCH)
}
} else if (currentThumbprint != null) {
// Migration: existing DPoP user upgraded — no thumbprint stored yet.
// Backfill so future checks can detect key rotation.
storage.store(KEY_DPOP_THUMBPRINT, currentThumbprint)
}

return null
}

/**
* Checks if the stored scope is the same as the requested one.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ public class CredentialsManager @VisibleForTesting(otherwise = VisibleForTesting
storage.store(KEY_EXPIRES_AT, credentials.expiresAt.time)
storage.store(KEY_SCOPE, credentials.scope)
storage.store(LEGACY_KEY_CACHE_EXPIRES_AT, credentials.expiresAt.time)
saveDPoPThumbprint(credentials)
}

/**
Expand Down Expand Up @@ -133,6 +134,12 @@ public class CredentialsManager @VisibleForTesting(otherwise = VisibleForTesting
return@execute
}

val tokenType = storage.retrieveString(KEY_TOKEN_TYPE)
validateDPoPState(tokenType)?.let { dpopError ->
callback.onFailure(dpopError)
return@execute
}

val request = authenticationClient.ssoExchange(refreshToken)
try {
if (parameters.isNotEmpty()) {
Expand Down Expand Up @@ -482,6 +489,10 @@ public class CredentialsManager @VisibleForTesting(otherwise = VisibleForTesting
callback.onFailure(CredentialsManagerException.NO_REFRESH_TOKEN)
return@execute
}
validateDPoPState(tokenType)?.let { dpopError ->
callback.onFailure(dpopError)
return@execute
}
val request = authenticationClient.renewAuth(refreshToken)
request.addParameters(parameters)
if (scope != null) {
Expand Down Expand Up @@ -592,8 +603,10 @@ public class CredentialsManager @VisibleForTesting(otherwise = VisibleForTesting
//Check if existing api credentials are present and valid
val key = getAPICredentialsKey(audience, scope)
val apiCredentialsJson = storage.retrieveString(key)
var apiCredentialType: String? = null
apiCredentialsJson?.let {
val apiCredentials = gson.fromJson(it, APICredentials::class.java)
apiCredentialType = apiCredentials.type
val willTokenExpire = willExpire(apiCredentials.expiresAt.time, minTtl.toLong())

val scopeChanged = hasScopeChanged(
Expand All @@ -616,6 +629,12 @@ public class CredentialsManager @VisibleForTesting(otherwise = VisibleForTesting
return@execute
}

val tokenType = apiCredentialType ?: storage.retrieveString(KEY_TOKEN_TYPE)
validateDPoPState(tokenType)?.let { dpopError ->
callback.onFailure(dpopError)
return@execute
}

val request = authenticationClient.renewAuth(refreshToken, audience, scope)
request.addParameters(parameters)

Expand Down Expand Up @@ -714,6 +733,7 @@ public class CredentialsManager @VisibleForTesting(otherwise = VisibleForTesting
storage.remove(KEY_EXPIRES_AT)
storage.remove(KEY_SCOPE)
storage.remove(LEGACY_KEY_CACHE_EXPIRES_AT)
storage.remove(KEY_DPOP_THUMBPRINT)
}

/**
Expand Down Expand Up @@ -761,7 +781,6 @@ public class CredentialsManager @VisibleForTesting(otherwise = VisibleForTesting
private const val KEY_ACCESS_TOKEN = "com.auth0.access_token"
private const val KEY_REFRESH_TOKEN = "com.auth0.refresh_token"
private const val KEY_ID_TOKEN = "com.auth0.id_token"
private const val KEY_TOKEN_TYPE = "com.auth0.token_type"
private const val KEY_EXPIRES_AT = "com.auth0.expires_at"
private const val KEY_SCOPE = "com.auth0.scope"

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,9 @@ public class CredentialsManagerException :
API_ERROR,
SSO_EXCHANGE_FAILED,
MFA_REQUIRED,
DPOP_KEY_MISSING,
DPOP_KEY_MISMATCH,
DPOP_NOT_CONFIGURED,
UNKNOWN_ERROR
}

Expand Down Expand Up @@ -159,6 +162,13 @@ public class CredentialsManagerException :
public val MFA_REQUIRED: CredentialsManagerException =
CredentialsManagerException(Code.MFA_REQUIRED)

public val DPOP_KEY_MISSING: CredentialsManagerException =
CredentialsManagerException(Code.DPOP_KEY_MISSING)
public val DPOP_KEY_MISMATCH: CredentialsManagerException =
CredentialsManagerException(Code.DPOP_KEY_MISMATCH)
public val DPOP_NOT_CONFIGURED: CredentialsManagerException =
CredentialsManagerException(Code.DPOP_NOT_CONFIGURED)

public val UNKNOWN_ERROR: CredentialsManagerException = CredentialsManagerException(Code.UNKNOWN_ERROR)


Expand Down Expand Up @@ -207,6 +217,9 @@ public class CredentialsManagerException :
Code.API_ERROR -> "An error occurred while processing the request."
Code.SSO_EXCHANGE_FAILED ->"The exchange of the refresh token for SSO credentials failed."
Code.MFA_REQUIRED -> "Multi-factor authentication is required to complete the credential renewal."
Code.DPOP_KEY_MISSING -> "The stored credentials are DPoP-bound but the DPoP key pair is no longer available in the Android KeyStore. Re-authentication is required."
Code.DPOP_KEY_MISMATCH -> "The stored credentials are DPoP-bound but the current DPoP key pair does not match the one used when credentials were saved. Re-authentication is required."
Code.DPOP_NOT_CONFIGURED -> "The stored credentials are DPoP-bound but the AuthenticationAPIClient used by this credentials manager was not configured with useDPoP(context). Call AuthenticationAPIClient(auth0).useDPoP(context) and pass the configured client to the credentials manager."
Code.UNKNOWN_ERROR -> "An unknown error has occurred while fetching the token. Please check the error cause for more details."
}
}
Expand Down
Loading
Loading