diff --git a/auth0/src/main/java/com/auth0/android/authentication/storage/BaseCredentialsManager.kt b/auth0/src/main/java/com/auth0/android/authentication/storage/BaseCredentialsManager.kt index 384265cc..be2aa173 100644 --- a/auth0/src/main/java/com/auth0/android/authentication/storage/BaseCredentialsManager.kt +++ b/auth0/src/main/java/com/auth0/android/authentication/storage/BaseCredentialsManager.kt @@ -194,6 +194,63 @@ public abstract class BaseCredentialsManager internal constructor( } } + /** + * 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. * diff --git a/auth0/src/main/java/com/auth0/android/authentication/storage/CredentialsManager.kt b/auth0/src/main/java/com/auth0/android/authentication/storage/CredentialsManager.kt index 4cc3144b..f9a56919 100644 --- a/auth0/src/main/java/com/auth0/android/authentication/storage/CredentialsManager.kt +++ b/auth0/src/main/java/com/auth0/android/authentication/storage/CredentialsManager.kt @@ -134,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()) { @@ -483,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) { @@ -593,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( @@ -617,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) diff --git a/auth0/src/main/java/com/auth0/android/authentication/storage/CredentialsManagerException.kt b/auth0/src/main/java/com/auth0/android/authentication/storage/CredentialsManagerException.kt index c141ae83..44c31f7d 100644 --- a/auth0/src/main/java/com/auth0/android/authentication/storage/CredentialsManagerException.kt +++ b/auth0/src/main/java/com/auth0/android/authentication/storage/CredentialsManagerException.kt @@ -49,6 +49,7 @@ public class CredentialsManagerException : SSO_EXCHANGE_FAILED, MFA_REQUIRED, DPOP_KEY_MISSING, + DPOP_KEY_MISMATCH, DPOP_NOT_CONFIGURED, UNKNOWN_ERROR } @@ -163,6 +164,8 @@ public class CredentialsManagerException : 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) @@ -215,6 +218,7 @@ public class CredentialsManagerException : 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." } diff --git a/auth0/src/main/java/com/auth0/android/authentication/storage/SecureCredentialsManager.kt b/auth0/src/main/java/com/auth0/android/authentication/storage/SecureCredentialsManager.kt index adcb883a..ef51b4d7 100644 --- a/auth0/src/main/java/com/auth0/android/authentication/storage/SecureCredentialsManager.kt +++ b/auth0/src/main/java/com/auth0/android/authentication/storage/SecureCredentialsManager.kt @@ -282,6 +282,12 @@ public class SecureCredentialsManager @VisibleForTesting(otherwise = VisibleForT return@execute } + val tokenType = storage.retrieveString(KEY_TOKEN_TYPE) ?: existingCredentials.type + validateDPoPState(tokenType)?.let { dpopError -> + callback.onFailure(dpopError) + return@execute + } + val request = authenticationClient.ssoExchange(existingCredentials.refreshToken) try { @@ -848,6 +854,11 @@ public class SecureCredentialsManager @VisibleForTesting(otherwise = VisibleForT callback.onFailure(CredentialsManagerException.NO_REFRESH_TOKEN) return@execute } + val tokenType = storage.retrieveString(KEY_TOKEN_TYPE) ?: credentials.type + validateDPoPState(tokenType)?.let { dpopError -> + callback.onFailure(dpopError) + return@execute + } Log.d(TAG, "Credentials have expired. Renewing them now...") val request = authenticationClient.renewAuth( credentials.refreshToken @@ -963,6 +974,7 @@ public class SecureCredentialsManager @VisibleForTesting(otherwise = VisibleForT val encryptedEncodedJson = storage.retrieveString(getAPICredentialsKey(audience, scope)) //Check if existing api credentials are present and valid + var apiCredentialType: String? = null encryptedEncodedJson?.let { encryptedEncoded -> val encrypted = Base64.decode(encryptedEncoded, Base64.DEFAULT) val json: String = try { @@ -987,6 +999,7 @@ public class SecureCredentialsManager @VisibleForTesting(otherwise = VisibleForT } val apiCredentials = gson.fromJson(json, APICredentials::class.java) + apiCredentialType = apiCredentials.type val expiresAt = apiCredentials.expiresAt.time val willAccessTokenExpire = willExpire(expiresAt, minTtl.toLong()) @@ -1014,6 +1027,12 @@ public class SecureCredentialsManager @VisibleForTesting(otherwise = VisibleForT return@execute } + val tokenType = apiCredentialType ?: storage.retrieveString(KEY_TOKEN_TYPE) ?: existingCredentials.type + validateDPoPState(tokenType)?.let { dpopError -> + callback.onFailure(dpopError) + return@execute + } + val request = authenticationClient.renewAuth(refreshToken, audience, scope) request.addParameters(parameters) for (header in headers) {