Skip to content

Commit 5995f36

Browse files
authored
feat: Add three new DPoP errors to CredentialsManagerException (#949)
1 parent 5c371a5 commit 5995f36

9 files changed

Lines changed: 860 additions & 52 deletions

File tree

EXAMPLES.md

Lines changed: 50 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -280,6 +280,21 @@ if (DPoP.isNonceRequiredError(response)) {
280280
}
281281
```
282282

283+
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.
284+
285+
```kotlin
286+
287+
val auth0 = Auth0.getInstance("YOUR_CLIENT_ID", "YOUR_DOMAIN")
288+
val apiClient = AuthenticationAPIClient(auth0).useDPoP(context) // DPoP enabled
289+
val storage = SharedPreferencesStorage(context)
290+
val manager = CredentialsManager(apiClient, storage)
291+
292+
WebAuthProvider
293+
.useDPoP()
294+
.login(auth0)
295+
.start(context, callback)
296+
```
297+
283298
On logout, you should call `DPoP.clearKeyPair()` to delete the user's key pair from the Keychain.
284299

285300
```kotlin
@@ -293,7 +308,7 @@ WebAuthProvider.logout(account)
293308

294309
})
295310
```
296-
> [!NOTE]
311+
> [!NOTE]
297312
> 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.
298313
299314
## Authentication API
@@ -1662,11 +1677,21 @@ val auth0 = Auth0.getInstance("YOUR_CLIENT_ID", "YOUR_DOMAIN")
16621677
val apiClient = AuthenticationAPIClient(auth0).useDPoP(this)
16631678
val storage = SharedPreferencesStorage(this)
16641679
val manager = SecureCredentialsManager(apiClient, this, auth0, storage)
1680+
```
1681+
1682+
Similarly, for `CredentialsManager`:
16651683

1684+
```kotlin
1685+
val auth0 = Auth0.getInstance("YOUR_CLIENT_ID", "YOUR_DOMAIN")
1686+
val apiClient = AuthenticationAPIClient(auth0).useDPoP(this)
1687+
val storage = SharedPreferencesStorage(this)
1688+
val manager = CredentialsManager(apiClient, storage)
16661689
```
16671690

1691+
> [!IMPORTANT]
1692+
> 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`.
16681693
1669-
> [!NOTE]
1694+
> [!NOTE]
16701695
> 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.
16711696
16721697

@@ -2587,24 +2612,42 @@ In the event that something happened while trying to save or retrieve the creden
25872612
- Tokens have expired but no `refresh_token` is available to perform a refresh credentials request.
25882613
- 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.
25892614
- 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`.
2615+
- **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.
2616+
- **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.
2617+
- **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.
25902618

2591-
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.
2619+
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.
25922620

2593-
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:
2621+
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:
25942622

25952623
```kotlin
25962624
when(credentialsManagerException) {
2597-
CredentialsManagerException.NO_CREDENTIALS - > {
2625+
CredentialsManagerException.NO_CREDENTIALS -> {
25982626
// handle no credentials scenario
25992627
}
26002628

2601-
CredentialsManagerException.NO_REFRESH_TOKEN - > {
2629+
CredentialsManagerException.NO_REFRESH_TOKEN -> {
26022630
// handle no refresh token scenario
26032631
}
26042632

2605-
CredentialsManagerException.STORE_FAILED - > {
2633+
CredentialsManagerException.STORE_FAILED -> {
26062634
// handle store failed scenario
26072635
}
2636+
2637+
CredentialsManagerException.DPOP_KEY_MISSING -> {
2638+
// DPoP key was lost
2639+
// Clear local state and prompt user to re-authenticate
2640+
}
2641+
2642+
CredentialsManagerException.DPOP_KEY_MISMATCH -> {
2643+
// DPoP key exists but doesn't match the one used at login (key rotation)
2644+
// Clear local state and prompt user to re-authenticate
2645+
}
2646+
2647+
CredentialsManagerException.DPOP_NOT_CONFIGURED -> {
2648+
// Developer forgot to call useDPoP() on the AuthenticationAPIClient
2649+
// passed to the credentials manager. Fix the client configuration.
2650+
}
26082651
// ... similarly for other error codes
26092652
}
26102653
```

auth0/src/main/java/com/auth0/android/authentication/AuthenticationAPIClient.kt

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,13 @@ public class AuthenticationAPIClient @VisibleForTesting(otherwise = VisibleForTe
5555

5656
private var dPoP: DPoP? = null
5757

58+
/**
59+
* Returns whether DPoP (Demonstrating Proof of Possession) is enabled on this client.
60+
* DPoP is enabled by calling [useDPoP].
61+
*/
62+
public val isDPoPEnabled: Boolean
63+
get() = dPoP != null
64+
5865
/**
5966
* Creates a new API client instance providing Auth0 account info.
6067
*

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

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import android.util.Log
44
import androidx.annotation.VisibleForTesting
55
import com.auth0.android.authentication.AuthenticationAPIClient
66
import com.auth0.android.callback.Callback
7+
import com.auth0.android.dpop.DPoPException
8+
import com.auth0.android.dpop.DPoPUtil
79
import com.auth0.android.result.APICredentials
810
import com.auth0.android.result.Credentials
911
import com.auth0.android.result.SSOCredentials
@@ -20,6 +22,14 @@ public abstract class BaseCredentialsManager internal constructor(
2022
protected val storage: Storage,
2123
private val jwtDecoder: JWTDecoder
2224
) {
25+
26+
internal companion object {
27+
internal const val KEY_DPOP_THUMBPRINT = "com.auth0.dpop_key_thumbprint"
28+
29+
@VisibleForTesting(otherwise = VisibleForTesting.PACKAGE_PRIVATE)
30+
internal const val KEY_TOKEN_TYPE = "com.auth0.token_type"
31+
}
32+
2333
private var _clock: Clock = ClockImpl()
2434

2535
/**
@@ -155,6 +165,92 @@ public abstract class BaseCredentialsManager internal constructor(
155165
internal val currentTimeInMillis: Long
156166
get() = _clock.getCurrentTimeMillis()
157167

168+
/**
169+
* Stores the DPoP key thumbprint if DPoP was used for this credential set.
170+
* Uses a dual strategy to store the thumbprint:
171+
* - credentials.type == "DPoP" when server confirms DPoP but client lacks useDPoP()
172+
* - isDPoPEnabled catches the case where client used DPoP, server returned token_type: "Bearer"
173+
*/
174+
protected fun saveDPoPThumbprint(credentials: Credentials) {
175+
val dpopUsed = credentials.type.equals("DPoP", ignoreCase = true)
176+
|| authenticationClient.isDPoPEnabled
177+
178+
if (!dpopUsed) {
179+
storage.remove(KEY_DPOP_THUMBPRINT)
180+
return
181+
}
182+
183+
val thumbprint = try {
184+
if (DPoPUtil.hasKeyPair()) DPoPUtil.getPublicKeyJWK() else null
185+
} catch (e: DPoPException) {
186+
Log.w(this::class.java.simpleName, "Failed to fetch DPoP key thumbprint", e)
187+
null
188+
}
189+
190+
if (thumbprint != null) {
191+
storage.store(KEY_DPOP_THUMBPRINT, thumbprint)
192+
} else {
193+
storage.remove(KEY_DPOP_THUMBPRINT)
194+
}
195+
}
196+
197+
/**
198+
* Validates DPoP key/token alignment before attempting a refresh.
199+
*
200+
* Uses two signals to detect DPoP-bound credentials:
201+
* - tokenType == "DPoP"
202+
* - KEY_DPOP_THUMBPRINT exists
203+
*
204+
* @param tokenType the token_type value from storage (or decrypted credentials for migration)
205+
* @return null if validation passes, or a CredentialsManagerException if it fails
206+
*/
207+
protected fun validateDPoPState(tokenType: String?): CredentialsManagerException? {
208+
val storedThumbprint = storage.retrieveString(KEY_DPOP_THUMBPRINT)
209+
val isDPoPBound = (tokenType?.equals("DPoP", ignoreCase = true) == true)
210+
|| (storedThumbprint != null)
211+
if (!isDPoPBound) return null
212+
213+
// Check 1: Does the DPoP key still exist in KeyStore?
214+
val hasKey = try {
215+
DPoPUtil.hasKeyPair()
216+
} catch (e: DPoPException) {
217+
Log.e(this::class.java.simpleName, "Failed to check DPoP key existence", e)
218+
false
219+
}
220+
if (!hasKey) {
221+
Log.w(this::class.java.simpleName, "DPoP key missing from KeyStore. Clearing stale credentials.")
222+
clearCredentials()
223+
return CredentialsManagerException(CredentialsManagerException.Code.DPOP_KEY_MISSING)
224+
}
225+
226+
// Check 2: Is the AuthenticationAPIClient configured with DPoP?
227+
if (!authenticationClient.isDPoPEnabled) {
228+
return CredentialsManagerException(CredentialsManagerException.Code.DPOP_NOT_CONFIGURED)
229+
}
230+
231+
// Check 3: Does the current key match the one used when credentials were saved?
232+
val currentThumbprint = try {
233+
DPoPUtil.getPublicKeyJWK()
234+
} catch (e: DPoPException) {
235+
Log.e(this::class.java.simpleName, "Failed to read DPoP key thumbprint", e)
236+
null
237+
}
238+
239+
if (storedThumbprint != null) {
240+
if (currentThumbprint != storedThumbprint) {
241+
Log.w(this::class.java.simpleName, "DPoP key thumbprint mismatch. The key pair has changed since credentials were saved. Clearing stale credentials.")
242+
clearCredentials()
243+
return CredentialsManagerException(CredentialsManagerException.Code.DPOP_KEY_MISMATCH)
244+
}
245+
} else if (currentThumbprint != null) {
246+
// Migration: existing DPoP user upgraded — no thumbprint stored yet.
247+
// Backfill so future checks can detect key rotation.
248+
storage.store(KEY_DPOP_THUMBPRINT, currentThumbprint)
249+
}
250+
251+
return null
252+
}
253+
158254
/**
159255
* Checks if the stored scope is the same as the requested one.
160256
*

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

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ public class CredentialsManager @VisibleForTesting(otherwise = VisibleForTesting
7575
storage.store(KEY_EXPIRES_AT, credentials.expiresAt.time)
7676
storage.store(KEY_SCOPE, credentials.scope)
7777
storage.store(LEGACY_KEY_CACHE_EXPIRES_AT, credentials.expiresAt.time)
78+
saveDPoPThumbprint(credentials)
7879
}
7980

8081
/**
@@ -133,6 +134,12 @@ public class CredentialsManager @VisibleForTesting(otherwise = VisibleForTesting
133134
return@execute
134135
}
135136

137+
val tokenType = storage.retrieveString(KEY_TOKEN_TYPE)
138+
validateDPoPState(tokenType)?.let { dpopError ->
139+
callback.onFailure(dpopError)
140+
return@execute
141+
}
142+
136143
val request = authenticationClient.ssoExchange(refreshToken)
137144
try {
138145
if (parameters.isNotEmpty()) {
@@ -482,6 +489,10 @@ public class CredentialsManager @VisibleForTesting(otherwise = VisibleForTesting
482489
callback.onFailure(CredentialsManagerException.NO_REFRESH_TOKEN)
483490
return@execute
484491
}
492+
validateDPoPState(tokenType)?.let { dpopError ->
493+
callback.onFailure(dpopError)
494+
return@execute
495+
}
485496
val request = authenticationClient.renewAuth(refreshToken)
486497
request.addParameters(parameters)
487498
if (scope != null) {
@@ -592,8 +603,10 @@ public class CredentialsManager @VisibleForTesting(otherwise = VisibleForTesting
592603
//Check if existing api credentials are present and valid
593604
val key = getAPICredentialsKey(audience, scope)
594605
val apiCredentialsJson = storage.retrieveString(key)
606+
var apiCredentialType: String? = null
595607
apiCredentialsJson?.let {
596608
val apiCredentials = gson.fromJson(it, APICredentials::class.java)
609+
apiCredentialType = apiCredentials.type
597610
val willTokenExpire = willExpire(apiCredentials.expiresAt.time, minTtl.toLong())
598611

599612
val scopeChanged = hasScopeChanged(
@@ -616,6 +629,12 @@ public class CredentialsManager @VisibleForTesting(otherwise = VisibleForTesting
616629
return@execute
617630
}
618631

632+
val tokenType = apiCredentialType ?: storage.retrieveString(KEY_TOKEN_TYPE)
633+
validateDPoPState(tokenType)?.let { dpopError ->
634+
callback.onFailure(dpopError)
635+
return@execute
636+
}
637+
619638
val request = authenticationClient.renewAuth(refreshToken, audience, scope)
620639
request.addParameters(parameters)
621640

@@ -714,6 +733,7 @@ public class CredentialsManager @VisibleForTesting(otherwise = VisibleForTesting
714733
storage.remove(KEY_EXPIRES_AT)
715734
storage.remove(KEY_SCOPE)
716735
storage.remove(LEGACY_KEY_CACHE_EXPIRES_AT)
736+
storage.remove(KEY_DPOP_THUMBPRINT)
717737
}
718738

719739
/**
@@ -761,7 +781,6 @@ public class CredentialsManager @VisibleForTesting(otherwise = VisibleForTesting
761781
private const val KEY_ACCESS_TOKEN = "com.auth0.access_token"
762782
private const val KEY_REFRESH_TOKEN = "com.auth0.refresh_token"
763783
private const val KEY_ID_TOKEN = "com.auth0.id_token"
764-
private const val KEY_TOKEN_TYPE = "com.auth0.token_type"
765784
private const val KEY_EXPIRES_AT = "com.auth0.expires_at"
766785
private const val KEY_SCOPE = "com.auth0.scope"
767786

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

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,9 @@ public class CredentialsManagerException :
4848
API_ERROR,
4949
SSO_EXCHANGE_FAILED,
5050
MFA_REQUIRED,
51+
DPOP_KEY_MISSING,
52+
DPOP_KEY_MISMATCH,
53+
DPOP_NOT_CONFIGURED,
5154
UNKNOWN_ERROR
5255
}
5356

@@ -159,6 +162,13 @@ public class CredentialsManagerException :
159162
public val MFA_REQUIRED: CredentialsManagerException =
160163
CredentialsManagerException(Code.MFA_REQUIRED)
161164

165+
public val DPOP_KEY_MISSING: CredentialsManagerException =
166+
CredentialsManagerException(Code.DPOP_KEY_MISSING)
167+
public val DPOP_KEY_MISMATCH: CredentialsManagerException =
168+
CredentialsManagerException(Code.DPOP_KEY_MISMATCH)
169+
public val DPOP_NOT_CONFIGURED: CredentialsManagerException =
170+
CredentialsManagerException(Code.DPOP_NOT_CONFIGURED)
171+
162172
public val UNKNOWN_ERROR: CredentialsManagerException = CredentialsManagerException(Code.UNKNOWN_ERROR)
163173

164174

@@ -207,6 +217,9 @@ public class CredentialsManagerException :
207217
Code.API_ERROR -> "An error occurred while processing the request."
208218
Code.SSO_EXCHANGE_FAILED ->"The exchange of the refresh token for SSO credentials failed."
209219
Code.MFA_REQUIRED -> "Multi-factor authentication is required to complete the credential renewal."
220+
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."
221+
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."
222+
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."
210223
Code.UNKNOWN_ERROR -> "An unknown error has occurred while fetching the token. Please check the error cause for more details."
211224
}
212225
}

0 commit comments

Comments
 (0)