Skip to content

Commit 2aa123c

Browse files
committed
Dpop thumbprint validation (#942)
1 parent ffd91da commit 2aa123c

File tree

4 files changed

+98
-0
lines changed

4 files changed

+98
-0
lines changed

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

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,63 @@ public abstract class BaseCredentialsManager internal constructor(
194194
}
195195
}
196196

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+
197254
/**
198255
* Checks if the stored scope is the same as the requested one.
199256
*

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

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,12 @@ public class CredentialsManager @VisibleForTesting(otherwise = VisibleForTesting
134134
return@execute
135135
}
136136

137+
val tokenType = storage.retrieveString(KEY_TOKEN_TYPE)
138+
validateDPoPState(tokenType)?.let { dpopError ->
139+
callback.onFailure(dpopError)
140+
return@execute
141+
}
142+
137143
val request = authenticationClient.ssoExchange(refreshToken)
138144
try {
139145
if (parameters.isNotEmpty()) {
@@ -483,6 +489,10 @@ public class CredentialsManager @VisibleForTesting(otherwise = VisibleForTesting
483489
callback.onFailure(CredentialsManagerException.NO_REFRESH_TOKEN)
484490
return@execute
485491
}
492+
validateDPoPState(tokenType)?.let { dpopError ->
493+
callback.onFailure(dpopError)
494+
return@execute
495+
}
486496
val request = authenticationClient.renewAuth(refreshToken)
487497
request.addParameters(parameters)
488498
if (scope != null) {
@@ -593,8 +603,10 @@ public class CredentialsManager @VisibleForTesting(otherwise = VisibleForTesting
593603
//Check if existing api credentials are present and valid
594604
val key = getAPICredentialsKey(audience, scope)
595605
val apiCredentialsJson = storage.retrieveString(key)
606+
var apiCredentialType: String? = null
596607
apiCredentialsJson?.let {
597608
val apiCredentials = gson.fromJson(it, APICredentials::class.java)
609+
apiCredentialType = apiCredentials.type
598610
val willTokenExpire = willExpire(apiCredentials.expiresAt.time, minTtl.toLong())
599611

600612
val scopeChanged = hasScopeChanged(
@@ -617,6 +629,12 @@ public class CredentialsManager @VisibleForTesting(otherwise = VisibleForTesting
617629
return@execute
618630
}
619631

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

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ public class CredentialsManagerException :
4949
SSO_EXCHANGE_FAILED,
5050
MFA_REQUIRED,
5151
DPOP_KEY_MISSING,
52+
DPOP_KEY_MISMATCH,
5253
DPOP_NOT_CONFIGURED,
5354
UNKNOWN_ERROR
5455
}
@@ -163,6 +164,8 @@ public class CredentialsManagerException :
163164

164165
public val DPOP_KEY_MISSING: CredentialsManagerException =
165166
CredentialsManagerException(Code.DPOP_KEY_MISSING)
167+
public val DPOP_KEY_MISMATCH: CredentialsManagerException =
168+
CredentialsManagerException(Code.DPOP_KEY_MISMATCH)
166169
public val DPOP_NOT_CONFIGURED: CredentialsManagerException =
167170
CredentialsManagerException(Code.DPOP_NOT_CONFIGURED)
168171

@@ -215,6 +218,7 @@ public class CredentialsManagerException :
215218
Code.SSO_EXCHANGE_FAILED ->"The exchange of the refresh token for SSO credentials failed."
216219
Code.MFA_REQUIRED -> "Multi-factor authentication is required to complete the credential renewal."
217220
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."
218222
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."
219223
Code.UNKNOWN_ERROR -> "An unknown error has occurred while fetching the token. Please check the error cause for more details."
220224
}

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

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -282,6 +282,12 @@ public class SecureCredentialsManager @VisibleForTesting(otherwise = VisibleForT
282282
return@execute
283283
}
284284

285+
val tokenType = storage.retrieveString(KEY_TOKEN_TYPE) ?: existingCredentials.type
286+
validateDPoPState(tokenType)?.let { dpopError ->
287+
callback.onFailure(dpopError)
288+
return@execute
289+
}
290+
285291
val request =
286292
authenticationClient.ssoExchange(existingCredentials.refreshToken)
287293
try {
@@ -848,6 +854,11 @@ public class SecureCredentialsManager @VisibleForTesting(otherwise = VisibleForT
848854
callback.onFailure(CredentialsManagerException.NO_REFRESH_TOKEN)
849855
return@execute
850856
}
857+
val tokenType = storage.retrieveString(KEY_TOKEN_TYPE) ?: credentials.type
858+
validateDPoPState(tokenType)?.let { dpopError ->
859+
callback.onFailure(dpopError)
860+
return@execute
861+
}
851862
Log.d(TAG, "Credentials have expired. Renewing them now...")
852863
val request = authenticationClient.renewAuth(
853864
credentials.refreshToken
@@ -963,6 +974,7 @@ public class SecureCredentialsManager @VisibleForTesting(otherwise = VisibleForT
963974
val encryptedEncodedJson = storage.retrieveString(getAPICredentialsKey(audience, scope))
964975
//Check if existing api credentials are present and valid
965976

977+
var apiCredentialType: String? = null
966978
encryptedEncodedJson?.let { encryptedEncoded ->
967979
val encrypted = Base64.decode(encryptedEncoded, Base64.DEFAULT)
968980
val json: String = try {
@@ -987,6 +999,7 @@ public class SecureCredentialsManager @VisibleForTesting(otherwise = VisibleForT
987999
}
9881000

9891001
val apiCredentials = gson.fromJson(json, APICredentials::class.java)
1002+
apiCredentialType = apiCredentials.type
9901003

9911004
val expiresAt = apiCredentials.expiresAt.time
9921005
val willAccessTokenExpire = willExpire(expiresAt, minTtl.toLong())
@@ -1014,6 +1027,12 @@ public class SecureCredentialsManager @VisibleForTesting(otherwise = VisibleForT
10141027
return@execute
10151028
}
10161029

1030+
val tokenType = apiCredentialType ?: storage.retrieveString(KEY_TOKEN_TYPE) ?: existingCredentials.type
1031+
validateDPoPState(tokenType)?.let { dpopError ->
1032+
callback.onFailure(dpopError)
1033+
return@execute
1034+
}
1035+
10171036
val request = authenticationClient.renewAuth(refreshToken, audience, scope)
10181037
request.addParameters(parameters)
10191038
for (header in headers) {

0 commit comments

Comments
 (0)