Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment on lines +207 to +211
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

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

This change introduces new DPoP validation behavior that can fail early (and in some cases clear stored credentials) during renewAuth(...) / ssoExchange(...). There are existing unit tests for both CredentialsManager and SecureCredentialsManager, but none appear to cover DPoP scenarios; please add tests that exercise the new paths (key missing, thumbprint mismatch, and client not configured) and assert both the returned CredentialsManagerException code and whether credentials are cleared/backfilled as intended.

Copilot uses AI. Check for mistakes.
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Tests will be added later


// 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
}
Comment on lines +232 to +237
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

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

validateDPoPState only catches DPoPException around DPoPUtil.getPublicKeyJWK(), but KeyStore.getKey(...) can also throw other runtime/checked exceptions (e.g., UnrecoverableKeyException, NoSuchAlgorithmException, ClassCastException) via DPoPKeyStore.getKeyPair(). As written, those would bubble up during token refresh and can crash the app. Consider broadening the catch to Exception (or Throwable if you want to be extra defensive) and returning a CredentialsManagerException instead of throwing.

Copilot uses AI. Check for mistakes.
Copy link
Contributor Author

Choose a reason for hiding this comment

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

This will be fixed as a separate PR at the root level


if (storedThumbprint != null) {
if (currentThumbprint != storedThumbprint) {
Copy link
Contributor

Choose a reason for hiding this comment

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

The key passed hasKeyPair() (Check 1), so it's not missing — it's a different key. Returning DPOP_KEY_MISSING here is misleading for developers debugging this path. Consider either a new code like DPOP_KEY_MISMATCH, or at minimum a distinct log message so it's distinguishable from the "key actually gone" case in Check 1.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Log is already added. I will add the new error type. Didn't add that initially as I thought it will be easier of dev side to just catch the DPOP_KEY_MISSING error and reinitiate a login flow. But a new error seems more DX friendly

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)
}
Comment on lines +239 to +244
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

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

When storedThumbprint is set, a failure to read currentThumbprint (resulting in null) will be treated as a thumbprint mismatch, which clears credentials and returns DPOP_KEY_MISSING. This can log users out on transient/unknown KeyStore read issues even though the key alias exists (you already checked hasKeyPair()). Consider handling currentThumbprint == null as an indeterminate state (e.g., return an error without clearing, or retry) and only clearing when you have a non-null thumbprint that actually differs.

Copilot uses AI. Check for mistakes.
Copy link
Contributor Author

Choose a reason for hiding this comment

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

This is intentional. hasKeyPair only checks if the KeyAlias exists or not. getPublicKeyJWK() actually reads the key and extracts the public key. If the alias exists but the key can't be read, the key is effectively unusable — any DPoP proof generation will also fail. So throwing error at this point is the correct approach

} 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 @@ -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()) {
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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(
Expand All @@ -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)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ public class CredentialsManagerException :
SSO_EXCHANGE_FAILED,
MFA_REQUIRED,
DPOP_KEY_MISSING,
DPOP_KEY_MISMATCH,
DPOP_NOT_CONFIGURED,
UNKNOWN_ERROR
}
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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."
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand All @@ -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())
Expand Down Expand Up @@ -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) {
Expand Down
Loading