diff --git a/EXAMPLES.md b/EXAMPLES.md index 12e2e4bca..da6c10333 100644 --- a/EXAMPLES.md +++ b/EXAMPLES.md @@ -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 @@ -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 @@ -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. @@ -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 } ``` diff --git a/auth0/src/main/java/com/auth0/android/authentication/AuthenticationAPIClient.kt b/auth0/src/main/java/com/auth0/android/authentication/AuthenticationAPIClient.kt index 572ecc513..f33aee9ca 100755 --- a/auth0/src/main/java/com/auth0/android/authentication/AuthenticationAPIClient.kt +++ b/auth0/src/main/java/com/auth0/android/authentication/AuthenticationAPIClient.kt @@ -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. * 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 d3ac32d59..be2aa1738 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 @@ -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 @@ -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() /** @@ -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. * 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 0cc5c61fa..f9a56919b 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 @@ -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) } /** @@ -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()) { @@ -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) { @@ -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( @@ -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) @@ -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) } /** @@ -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" 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 9796dbe64..44c31f7d8 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 @@ -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 } @@ -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) @@ -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." } } 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 4a367e778..ef51b4d7a 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 @@ -189,6 +189,8 @@ public class SecureCredentialsManager @VisibleForTesting(otherwise = VisibleForT ) storage.store(LEGACY_KEY_CACHE_EXPIRES_AT, credentials.expiresAt.time) storage.store(KEY_CAN_REFRESH, canRefresh) + storage.store(KEY_TOKEN_TYPE, credentials.type) + saveDPoPThumbprint(credentials) } catch (e: IncompatibleDeviceException) { throw CredentialsManagerException( CredentialsManagerException.Code.INCOMPATIBLE_DEVICE, e @@ -280,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 { @@ -735,6 +743,8 @@ public class SecureCredentialsManager @VisibleForTesting(otherwise = VisibleForT storage.remove(KEY_EXPIRES_AT) storage.remove(LEGACY_KEY_CACHE_EXPIRES_AT) storage.remove(KEY_CAN_REFRESH) + storage.remove(KEY_TOKEN_TYPE) + storage.remove(KEY_DPOP_THUMBPRINT) clearBiometricSession() Log.d(TAG, "Credentials were just removed from the storage") } @@ -844,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 @@ -893,7 +908,8 @@ public class SecureCredentialsManager @VisibleForTesting(otherwise = VisibleForT callback.onFailure( CredentialsManagerException( CredentialsManagerException.Code.MFA_REQUIRED, - error.message ?: "Multi-factor authentication is required to complete the credential renewal.", + error.message + ?: "Multi-factor authentication is required to complete the credential renewal.", error, error.mfaRequiredErrorPayload ) @@ -958,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 { @@ -982,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()) @@ -1009,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) { @@ -1051,7 +1075,8 @@ public class SecureCredentialsManager @VisibleForTesting(otherwise = VisibleForT callback.onFailure( CredentialsManagerException( CredentialsManagerException.Code.MFA_REQUIRED, - error.message ?: "Multi-factor authentication is required to complete the credential renewal.", + error.message + ?: "Multi-factor authentication is required to complete the credential renewal.", error, error.mfaRequiredErrorPayload ) @@ -1251,6 +1276,7 @@ public class SecureCredentialsManager @VisibleForTesting(otherwise = VisibleForT @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) internal const val KEY_ALIAS = "com.auth0.key" + // Using NO_SESSION to represent "no session" (uninitialized state) private const val NO_SESSION = -1L } diff --git a/auth0/src/main/java/com/auth0/android/dpop/DPoP.kt b/auth0/src/main/java/com/auth0/android/dpop/DPoP.kt index d84f7f610..b0490575e 100644 --- a/auth0/src/main/java/com/auth0/android/dpop/DPoP.kt +++ b/auth0/src/main/java/com/auth0/android/dpop/DPoP.kt @@ -198,6 +198,29 @@ public class DPoP(context: Context) { return HeaderData(token, proof) } + /** + * Returns whether a DPoP key pair currently exists in the Android KeyStore. + * + * This can be used to check if DPoP credentials are still available after events + * like device backup/restore or factory reset, which do not preserve KeyStore entries. + * + * ```kotlin + * + * if (!DPoP.hasKeyPair()) { + * // Key was lost — clear stored credentials and re-authenticate + * } + * + * ``` + * + * @return true if a DPoP key pair exists in the KeyStore, false otherwise. + * @throws DPoPException if there is an error accessing the KeyStore. + */ + @Throws(DPoPException::class) + @JvmStatic + public fun hasKeyPair(): Boolean { + return DPoPUtil.hasKeyPair() + } + /** * Method to clear the DPoP key pair from the keystore. It must be called when the user logs out * to prevent reuse of the key pair in subsequent sessions. diff --git a/auth0/src/test/java/com/auth0/android/authentication/storage/CredentialsManagerTest.kt b/auth0/src/test/java/com/auth0/android/authentication/storage/CredentialsManagerTest.kt index 4eee35709..42668a9ad 100644 --- a/auth0/src/test/java/com/auth0/android/authentication/storage/CredentialsManagerTest.kt +++ b/auth0/src/test/java/com/auth0/android/authentication/storage/CredentialsManagerTest.kt @@ -4,6 +4,10 @@ import com.auth0.android.NetworkErrorException import com.auth0.android.authentication.AuthenticationAPIClient import com.auth0.android.authentication.AuthenticationException import com.auth0.android.callback.Callback +import com.auth0.android.dpop.DPoPKeyStore +import com.auth0.android.dpop.DPoPUtil +import com.auth0.android.dpop.FakeECPrivateKey +import com.auth0.android.dpop.FakeECPublicKey import com.auth0.android.request.Request import com.auth0.android.request.internal.GsonProvider import com.auth0.android.request.internal.Jwt @@ -26,6 +30,7 @@ import com.nhaarman.mockitokotlin2.times import com.nhaarman.mockitokotlin2.verify import com.nhaarman.mockitokotlin2.verifyNoMoreInteractions import com.nhaarman.mockitokotlin2.verifyZeroInteractions +import com.nhaarman.mockitokotlin2.whenever import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.runBlocking import kotlinx.coroutines.test.runTest @@ -33,6 +38,7 @@ import org.hamcrest.MatcherAssert import org.hamcrest.Matchers import org.hamcrest.core.Is import org.hamcrest.core.IsInstanceOf +import org.junit.After import org.junit.Assert import org.junit.Assert.assertThrows import org.junit.Before @@ -74,6 +80,10 @@ public class CredentialsManagerTest { @Mock private lateinit var jwtDecoder: JWTDecoder + private lateinit var mockDPoPKeyStore: DPoPKeyStore + private val fakePublicKey = FakeECPublicKey() + private val fakePrivateKey = FakeECPrivateKey() + private val serialExecutor = Executor { runnable -> runnable.run() } private val credentialsCaptor: KArgumentCaptor = argumentCaptor() @@ -92,6 +102,10 @@ public class CredentialsManagerTest { @Before public fun setUp() { MockitoAnnotations.openMocks(this) + mockDPoPKeyStore = mock() + DPoPUtil.keyStore = mockDPoPKeyStore + whenever(mockDPoPKeyStore.hasKeyPair()).thenReturn(false) + val credentialsManager = CredentialsManager(client, storage, jwtDecoder, serialExecutor) manager = Mockito.spy(credentialsManager) //Needed to test expiration verification @@ -135,6 +149,7 @@ public class CredentialsManagerTest { verify(storage).store("com.auth0.expires_at", expirationTime) verify(storage).store("com.auth0.scope", "scope") verify(storage).store("com.auth0.cache_expires_at", expirationTime) + verify(storage).remove("com.auth0.dpop_key_thumbprint") verifyNoMoreInteractions(storage) } @@ -158,6 +173,7 @@ public class CredentialsManagerTest { verify(storage).store("com.auth0.expires_at", accessTokenExpirationTime) verify(storage).store("com.auth0.scope", "scope") verify(storage).store("com.auth0.cache_expires_at", accessTokenExpirationTime) + verify(storage).remove("com.auth0.dpop_key_thumbprint") verifyNoMoreInteractions(storage) } @@ -182,6 +198,7 @@ public class CredentialsManagerTest { verify(storage).store("com.auth0.expires_at", accessTokenExpirationTime) verify(storage).store("com.auth0.scope", "scope") verify(storage).store("com.auth0.cache_expires_at", accessTokenExpirationTime) + verify(storage).remove("com.auth0.dpop_key_thumbprint") verifyNoMoreInteractions(storage) } @@ -206,6 +223,7 @@ public class CredentialsManagerTest { verify(storage).store("com.auth0.expires_at", expirationTime) verify(storage).store("com.auth0.scope", "scope") verify(storage).store("com.auth0.cache_expires_at", expirationTime) + verify(storage).remove("com.auth0.dpop_key_thumbprint") verifyNoMoreInteractions(storage) } @@ -433,7 +451,8 @@ public class CredentialsManagerTest { "token", "type", Date(accessTokenExpiry), "scope" ) - Mockito.`when`(storage.retrieveString("audience::scope")).thenReturn(gson.toJson(apiCredentials)) + Mockito.`when`(storage.retrieveString("audience::scope")) + .thenReturn(gson.toJson(apiCredentials)) manager.getApiCredentials("audience", "scope", callback = apiCredentialsCallback) verify(apiCredentialsCallback).onSuccess(apiCredentialsCaptor.capture()) val retrievedCredentials = apiCredentialsCaptor.firstValue @@ -482,7 +501,10 @@ public class CredentialsManagerTest { verify(storage).store("com.auth0.id_token", renewedCredentials.idToken) // RefreshToken should not be replaced verify(storage).store("com.auth0.refresh_token", "refreshToken") - verify(storage).store("audience::newScope", gson.toJson(renewedCredentials.toAPICredentials())) + verify(storage).store( + "audience::newScope", + gson.toJson(renewedCredentials.toAPICredentials()) + ) // Verify the returned credentials are the latest val newAPiCredentials = apiCredentialsCaptor.firstValue MatcherAssert.assertThat(newAPiCredentials, Is.`is`(Matchers.notNullValue())) @@ -500,7 +522,8 @@ public class CredentialsManagerTest { "token", "type", Date(accessTokenExpiry), "scope" ) - Mockito.`when`(storage.retrieveString("audience::scope")).thenReturn(gson.toJson(apiCredentials)) + Mockito.`when`(storage.retrieveString("audience::scope")) + .thenReturn(gson.toJson(apiCredentials)) Mockito.`when`(storage.retrieveString("com.auth0.refresh_token")).thenReturn("refreshToken") Mockito.`when`( client.renewAuth("refreshToken", "audience", "scope") @@ -542,7 +565,8 @@ public class CredentialsManagerTest { "token", "type", Date(accessTokenExpiry), "scope" ) - Mockito.`when`(storage.retrieveString("audience::scope")).thenReturn(gson.toJson(apiCredentials)) + Mockito.`when`(storage.retrieveString("audience::scope")) + .thenReturn(gson.toJson(apiCredentials)) Mockito.`when`(storage.retrieveString("com.auth0.refresh_token")).thenReturn("refreshToken") Mockito.`when`( client.renewAuth("refreshToken", "audience", "scope") @@ -557,7 +581,12 @@ public class CredentialsManagerTest { val renewedCredentials = Credentials("newId", "newAccess", "newType", newRefresh, newDate, "scope") Mockito.`when`(request.execute()).thenReturn(renewedCredentials) - manager.getApiCredentials("audience", "scope", minTtl = 10, callback = apiCredentialsCallback) + manager.getApiCredentials( + "audience", + "scope", + minTtl = 10, + callback = apiCredentialsCallback + ) verify(apiCredentialsCallback).onSuccess( apiCredentialsCaptor.capture() ) @@ -582,7 +611,7 @@ public class CredentialsManagerTest { Mockito.`when`(storage.retrieveString("audience")).thenReturn(null) Mockito.`when`(storage.retrieveString("com.auth0.refresh_token")).thenReturn("refreshToken") Mockito.`when`( - client.renewAuth("refreshToken", "audience","newScope") + client.renewAuth("refreshToken", "audience", "newScope") ).thenReturn(request) val newDate = Date(CredentialsMock.ONE_HOUR_AHEAD_MS + ONE_HOUR_SECONDS * 1000) val jwtMock = mock() @@ -602,7 +631,10 @@ public class CredentialsManagerTest { verify(storage).store("com.auth0.id_token", renewedCredentials.idToken) // RefreshToken should be replaced verify(storage).store("com.auth0.refresh_token", "newRefreshToken") - verify(storage).store("audience::newScope", gson.toJson(renewedCredentials.toAPICredentials())) + verify(storage).store( + "audience::newScope", + gson.toJson(renewedCredentials.toAPICredentials()) + ) // Verify the returned credentials are the latest val newAPiCredentials = apiCredentialsCaptor.firstValue MatcherAssert.assertThat(newAPiCredentials, Is.`is`(Matchers.notNullValue())) @@ -630,7 +662,12 @@ public class CredentialsManagerTest { val renewedCredentials = Credentials("newId", "newAccess", "newType", newRefresh, newDate, "newScope") Mockito.`when`(request.execute()).thenReturn(renewedCredentials) - manager.getApiCredentials("audience", "newScope", minTtl = 1, callback = apiCredentialsCallback) + manager.getApiCredentials( + "audience", + "newScope", + minTtl = 1, + callback = apiCredentialsCallback + ) verify(apiCredentialsCallback).onFailure( exceptionCaptor.capture() ) @@ -651,7 +688,8 @@ public class CredentialsManagerTest { "token", "type", Date(accessTokenExpiry), "scope" ) - Mockito.`when`(storage.retrieveString("audience::scope")).thenReturn(gson.toJson(apiCredentials)) + Mockito.`when`(storage.retrieveString("audience::scope")) + .thenReturn(gson.toJson(apiCredentials)) val retrievedCredentials = manager.awaitApiCredentials("audience", "scope") MatcherAssert.assertThat(retrievedCredentials, Is.`is`(Matchers.notNullValue())) Assert.assertEquals(retrievedCredentials.accessToken, apiCredentials.accessToken) @@ -673,7 +711,7 @@ public class CredentialsManagerTest { Mockito.`when`(jwtMock.expiresAt).thenReturn(newDate) Mockito.`when`(jwtDecoder.decode("newId")).thenReturn(jwtMock) val renewedCredentials = - Credentials("newId", "newAccess", "newType",null, newDate, "newScope") + Credentials("newId", "newAccess", "newType", null, newDate, "newScope") Mockito.`when`(request.execute()).thenReturn(renewedCredentials) val retrievedCredentials = manager.awaitApiCredentials("audience") MatcherAssert.assertThat(retrievedCredentials, Is.`is`(Matchers.notNullValue())) @@ -912,7 +950,7 @@ public class CredentialsManagerTest { verify(storage).store( "com.auth0.cache_expires_at", renewedCredentials.expiresAt.time ) - verify(storage, never()).remove(ArgumentMatchers.anyString()) + verify(storage).remove("com.auth0.dpop_key_thumbprint") // Verify the returned credentials are the latest val retrievedCredentials = credentialsCaptor.firstValue @@ -970,7 +1008,7 @@ public class CredentialsManagerTest { verify(storage).store( "com.auth0.cache_expires_at", renewedCredentials.expiresAt.time ) - verify(storage, never()).remove(ArgumentMatchers.anyString()) + verify(storage).remove("com.auth0.dpop_key_thumbprint") // Verify the returned credentials are the latest val retrievedCredentials = credentialsCaptor.firstValue @@ -1027,7 +1065,7 @@ public class CredentialsManagerTest { verify(storage).store( "com.auth0.cache_expires_at", renewedCredentials.expiresAt.time ) - verify(storage, never()).remove(ArgumentMatchers.anyString()) + verify(storage).remove("com.auth0.dpop_key_thumbprint") // Verify the returned credentials are the latest val retrievedCredentials = credentialsCaptor.firstValue @@ -1086,7 +1124,7 @@ public class CredentialsManagerTest { verify(storage).store( "com.auth0.cache_expires_at", renewedCredentials.expiresAt.time ) - verify(storage, never()).remove(ArgumentMatchers.anyString()) + verify(storage).remove("com.auth0.dpop_key_thumbprint") // Verify the returned credentials are the latest val retrievedCredentials = credentialsCaptor.firstValue @@ -1144,7 +1182,7 @@ public class CredentialsManagerTest { verify(storage).store( "com.auth0.cache_expires_at", renewedCredentials.expiresAt.time ) - verify(storage, never()).remove(ArgumentMatchers.anyString()) + verify(storage).remove("com.auth0.dpop_key_thumbprint") //// Verify the returned credentials are the latest val retrievedCredentials = credentialsCaptor.firstValue @@ -1252,7 +1290,7 @@ public class CredentialsManagerTest { verify(storage).store( "com.auth0.cache_expires_at", renewedCredentials.expiresAt.time ) - verify(storage, never()).remove(ArgumentMatchers.anyString()) + verify(storage).remove("com.auth0.dpop_key_thumbprint") //// Verify the returned credentials are the latest val retrievedCredentials = credentialsCaptor.firstValue @@ -1482,6 +1520,7 @@ public class CredentialsManagerTest { verify(storage).remove("com.auth0.expires_at") verify(storage).remove("com.auth0.scope") verify(storage).remove("com.auth0.cache_expires_at") + verify(storage).remove("com.auth0.dpop_key_thumbprint") verifyNoMoreInteractions(storage) } @@ -1959,7 +1998,8 @@ public class CredentialsManagerTest { Mockito.`when`(storage.retrieveString("com.auth0.token_type")).thenReturn("type") val expirationTime = CredentialsMock.CURRENT_TIME_MS // Expired Mockito.`when`(storage.retrieveLong("com.auth0.expires_at")).thenReturn(expirationTime) - Mockito.`when`(storage.retrieveLong("com.auth0.cache_expires_at")).thenReturn(expirationTime) + Mockito.`when`(storage.retrieveLong("com.auth0.cache_expires_at")) + .thenReturn(expirationTime) Mockito.`when`(storage.retrieveString("com.auth0.scope")).thenReturn("scope") Mockito.`when`(client.renewAuth("refreshToken")).thenReturn(request) @@ -1975,10 +2015,10 @@ public class CredentialsManagerTest { ) ) val mfaRequiredException = AuthenticationException(mfaRequiredValues, 403) - + MatcherAssert.assertThat(mfaRequiredException.isMultifactorRequired, Is.`is`(true)) MatcherAssert.assertThat(mfaRequiredException.getCode(), Is.`is`("mfa_required")) - + Mockito.`when`(request.execute()).thenThrow(mfaRequiredException) manager.getCredentials(callback) @@ -1988,11 +2028,20 @@ public class CredentialsManagerTest { MatcherAssert.assertThat(exception, Is.`is`(Matchers.notNullValue())) MatcherAssert.assertThat(exception.message, Matchers.containsString("authenticate")) MatcherAssert.assertThat(exception.cause, Is.`is`(mfaRequiredException)) - - MatcherAssert.assertThat(exception.mfaRequiredErrorPayload, Is.`is`(Matchers.notNullValue())) + + MatcherAssert.assertThat( + exception.mfaRequiredErrorPayload, + Is.`is`(Matchers.notNullValue()) + ) MatcherAssert.assertThat(exception.mfaToken, Is.`is`("test-mfa-token-12345")) - MatcherAssert.assertThat(exception.mfaRequiredErrorPayload?.mfaRequirements?.challenge, Is.`is`(Matchers.notNullValue())) - MatcherAssert.assertThat(exception.mfaRequiredErrorPayload?.mfaRequirements?.challenge?.size, Is.`is`(2)) + MatcherAssert.assertThat( + exception.mfaRequiredErrorPayload?.mfaRequirements?.challenge, + Is.`is`(Matchers.notNullValue()) + ) + MatcherAssert.assertThat( + exception.mfaRequiredErrorPayload?.mfaRequirements?.challenge?.size, + Is.`is`(2) + ) } @Test @@ -2003,7 +2052,8 @@ public class CredentialsManagerTest { Mockito.`when`(storage.retrieveString("com.auth0.token_type")).thenReturn("type") val expirationTime = CredentialsMock.CURRENT_TIME_MS // Expired Mockito.`when`(storage.retrieveLong("com.auth0.expires_at")).thenReturn(expirationTime) - Mockito.`when`(storage.retrieveLong("com.auth0.cache_expires_at")).thenReturn(expirationTime) + Mockito.`when`(storage.retrieveLong("com.auth0.cache_expires_at")) + .thenReturn(expirationTime) Mockito.`when`(storage.retrieveString("com.auth0.scope")).thenReturn("scope") Mockito.`when`(client.renewAuth("refreshToken")).thenReturn(request) @@ -2028,9 +2078,18 @@ public class CredentialsManagerTest { val exception = exceptionCaptor.firstValue MatcherAssert.assertThat(exception.message, Matchers.containsString("authenticate")) MatcherAssert.assertThat(exception.mfaToken, Is.`is`("enroll-mfa-token")) - MatcherAssert.assertThat(exception.mfaRequiredErrorPayload?.mfaRequirements?.enroll, Is.`is`(Matchers.notNullValue())) - MatcherAssert.assertThat(exception.mfaRequiredErrorPayload?.mfaRequirements?.enroll?.size, Is.`is`(3)) - MatcherAssert.assertThat(exception.mfaRequiredErrorPayload?.mfaRequirements?.challenge, Is.`is`(Matchers.nullValue())) + MatcherAssert.assertThat( + exception.mfaRequiredErrorPayload?.mfaRequirements?.enroll, + Is.`is`(Matchers.notNullValue()) + ) + MatcherAssert.assertThat( + exception.mfaRequiredErrorPayload?.mfaRequirements?.enroll?.size, + Is.`is`(3) + ) + MatcherAssert.assertThat( + exception.mfaRequiredErrorPayload?.mfaRequirements?.challenge, + Is.`is`(Matchers.nullValue()) + ) } @Test @@ -2041,7 +2100,8 @@ public class CredentialsManagerTest { Mockito.`when`(storage.retrieveString("com.auth0.token_type")).thenReturn("type") val expirationTime = CredentialsMock.CURRENT_TIME_MS // Expired Mockito.`when`(storage.retrieveLong("com.auth0.expires_at")).thenReturn(expirationTime) - Mockito.`when`(storage.retrieveLong("com.auth0.cache_expires_at")).thenReturn(expirationTime) + Mockito.`when`(storage.retrieveLong("com.auth0.cache_expires_at")) + .thenReturn(expirationTime) Mockito.`when`(storage.retrieveString("com.auth0.scope")).thenReturn("scope") Mockito.`when`(client.renewAuth("refreshToken")).thenReturn(request) @@ -2059,7 +2119,10 @@ public class CredentialsManagerTest { verify(callback).onFailure(exceptionCaptor.capture()) val exception = exceptionCaptor.firstValue - MatcherAssert.assertThat(exception.message, Matchers.containsString("processing the request")) + MatcherAssert.assertThat( + exception.message, + Matchers.containsString("processing the request") + ) MatcherAssert.assertThat(exception.mfaRequiredErrorPayload, Is.`is`(Matchers.nullValue())) MatcherAssert.assertThat(exception.mfaToken, Is.`is`(Matchers.nullValue())) } @@ -2073,7 +2136,8 @@ public class CredentialsManagerTest { Mockito.`when`(storage.retrieveString("com.auth0.token_type")).thenReturn("type") val expirationTime = CredentialsMock.CURRENT_TIME_MS // Expired Mockito.`when`(storage.retrieveLong("com.auth0.expires_at")).thenReturn(expirationTime) - Mockito.`when`(storage.retrieveLong("com.auth0.cache_expires_at")).thenReturn(expirationTime) + Mockito.`when`(storage.retrieveLong("com.auth0.cache_expires_at")) + .thenReturn(expirationTime) Mockito.`when`(storage.retrieveString("com.auth0.scope")).thenReturn("scope") Mockito.`when`(client.renewAuth("refreshToken")).thenReturn(request) @@ -2095,7 +2159,10 @@ public class CredentialsManagerTest { } MatcherAssert.assertThat(exception, Is.`is`(Matchers.notNullValue())) MatcherAssert.assertThat(exception.cause, Is.`is`(mfaRequiredException)) - MatcherAssert.assertThat(exception.mfaRequiredErrorPayload, Is.`is`(Matchers.notNullValue())) + MatcherAssert.assertThat( + exception.mfaRequiredErrorPayload, + Is.`is`(Matchers.notNullValue()) + ) MatcherAssert.assertThat(exception.mfaToken, Is.`is`("await-mfa-token-12345")) } @@ -2107,7 +2174,8 @@ public class CredentialsManagerTest { Mockito.`when`(storage.retrieveString("com.auth0.token_type")).thenReturn("type") val expirationTime = CredentialsMock.CURRENT_TIME_MS // Expired Mockito.`when`(storage.retrieveLong("com.auth0.expires_at")).thenReturn(expirationTime) - Mockito.`when`(storage.retrieveLong("com.auth0.cache_expires_at")).thenReturn(expirationTime) + Mockito.`when`(storage.retrieveLong("com.auth0.cache_expires_at")) + .thenReturn(expirationTime) Mockito.`when`(storage.retrieveString("com.auth0.scope")).thenReturn("scope") Mockito.`when`(client.renewAuth("refreshToken")).thenReturn(request) @@ -2123,14 +2191,263 @@ public class CredentialsManagerTest { verify(callback).onFailure(exceptionCaptor.capture()) val exception = exceptionCaptor.firstValue - + MatcherAssert.assertThat(exception.cause, Is.`is`(Matchers.notNullValue())) - MatcherAssert.assertThat(exception.cause, IsInstanceOf.instanceOf(AuthenticationException::class.java)) - + MatcherAssert.assertThat( + exception.cause, + IsInstanceOf.instanceOf(AuthenticationException::class.java) + ) + val causeException = exception.cause as AuthenticationException MatcherAssert.assertThat(causeException.getCode(), Is.`is`("mfa_required")) MatcherAssert.assertThat(causeException.isMultifactorRequired, Is.`is`(true)) - MatcherAssert.assertThat(causeException.getDescription(), Is.`is`("MFA is required for this action")) + MatcherAssert.assertThat( + causeException.getDescription(), + Is.`is`("MFA is required for this action") + ) + } + + @Test + public fun shouldStoreDPoPThumbprintWhenCredentialsTypeIsDPoP() { + val expirationTime = CredentialsMock.ONE_HOUR_AHEAD_MS + val credentials = CredentialsMock.create( + "idToken", "accessToken", "DPoP", "refreshToken", Date(expirationTime), "scope" + ) + prepareJwtDecoderMock(Date(expirationTime)) + whenever(mockDPoPKeyStore.hasKeyPair()).thenReturn(true) + whenever(mockDPoPKeyStore.getKeyPair()).thenReturn(Pair(fakePrivateKey, fakePublicKey)) + + manager.saveCredentials(credentials) + + verify(storage).store(eq("com.auth0.dpop_key_thumbprint"), ArgumentMatchers.anyString()) + verify(storage, never()).remove("com.auth0.dpop_key_thumbprint") + } + + @Test + public fun shouldStoreDPoPThumbprintWhenIsDPoPEnabledIsTrue() { + val expirationTime = CredentialsMock.ONE_HOUR_AHEAD_MS + val credentials = CredentialsMock.create( + "idToken", "accessToken", "Bearer", "refreshToken", Date(expirationTime), "scope" + ) + prepareJwtDecoderMock(Date(expirationTime)) + Mockito.doReturn(true).`when`(client).isDPoPEnabled + whenever(mockDPoPKeyStore.hasKeyPair()).thenReturn(true) + whenever(mockDPoPKeyStore.getKeyPair()).thenReturn(Pair(fakePrivateKey, fakePublicKey)) + + manager.saveCredentials(credentials) + + verify(storage).store(eq("com.auth0.dpop_key_thumbprint"), ArgumentMatchers.anyString()) + verify(storage, never()).remove("com.auth0.dpop_key_thumbprint") + } + + @Test + public fun shouldRemoveDPoPThumbprintForBearerCredentialsWithoutDPoP() { + val expirationTime = CredentialsMock.ONE_HOUR_AHEAD_MS + val credentials = CredentialsMock.create( + "idToken", "accessToken", "Bearer", "refreshToken", Date(expirationTime), "scope" + ) + prepareJwtDecoderMock(Date(expirationTime)) + + manager.saveCredentials(credentials) + + verify(storage).remove("com.auth0.dpop_key_thumbprint") + verify(storage, never()).store( + eq("com.auth0.dpop_key_thumbprint"), + ArgumentMatchers.anyString() + ) + } + + @Test + public fun shouldRemoveDPoPThumbprintWhenNoKeyPairExists() { + val expirationTime = CredentialsMock.ONE_HOUR_AHEAD_MS + val credentials = CredentialsMock.create( + "idToken", "accessToken", "DPoP", "refreshToken", Date(expirationTime), "scope" + ) + prepareJwtDecoderMock(Date(expirationTime)) + whenever(mockDPoPKeyStore.hasKeyPair()).thenReturn(false) + + manager.saveCredentials(credentials) + + verify(storage).remove("com.auth0.dpop_key_thumbprint") + verify(storage, never()).store( + eq("com.auth0.dpop_key_thumbprint"), + ArgumentMatchers.anyString() + ) + } + + @Test + public fun shouldFailOnGetCredentialsWithDPoPKeyMissingWhenKeyNotInKeyStore() { + Mockito.`when`(storage.retrieveString("com.auth0.id_token")).thenReturn("idToken") + Mockito.`when`(storage.retrieveString("com.auth0.access_token")).thenReturn("accessToken") + Mockito.`when`(storage.retrieveString("com.auth0.refresh_token")).thenReturn("refreshToken") + Mockito.`when`(storage.retrieveString("com.auth0.token_type")).thenReturn("DPoP") + val expirationTime = CredentialsMock.CURRENT_TIME_MS // expired + Mockito.`when`(storage.retrieveLong("com.auth0.expires_at")).thenReturn(expirationTime) + Mockito.`when`(storage.retrieveString("com.auth0.scope")).thenReturn("scope") + whenever(mockDPoPKeyStore.hasKeyPair()).thenReturn(false) + + manager.getCredentials(callback) + + verify(callback).onFailure(exceptionCaptor.capture()) + val exception = exceptionCaptor.firstValue + MatcherAssert.assertThat(exception, Is.`is`(CredentialsManagerException.DPOP_KEY_MISSING)) + } + + @Test + public fun shouldFailOnGetCredentialsWithDPoPNotConfiguredWhenClientNotSetup() { + Mockito.`when`(storage.retrieveString("com.auth0.id_token")).thenReturn("idToken") + Mockito.`when`(storage.retrieveString("com.auth0.access_token")).thenReturn("accessToken") + Mockito.`when`(storage.retrieveString("com.auth0.refresh_token")).thenReturn("refreshToken") + Mockito.`when`(storage.retrieveString("com.auth0.token_type")).thenReturn("DPoP") + val expirationTime = CredentialsMock.CURRENT_TIME_MS // expired + Mockito.`when`(storage.retrieveLong("com.auth0.expires_at")).thenReturn(expirationTime) + Mockito.`when`(storage.retrieveString("com.auth0.scope")).thenReturn("scope") + whenever(mockDPoPKeyStore.hasKeyPair()).thenReturn(true) + + manager.getCredentials(callback) + + verify(callback).onFailure(exceptionCaptor.capture()) + val exception = exceptionCaptor.firstValue + MatcherAssert.assertThat( + exception, + Is.`is`(CredentialsManagerException.DPOP_NOT_CONFIGURED) + ) + } + + @Test + public fun shouldFailOnGetCredentialsWithDPoPKeyMismatchWhenThumbprintsDontMatch() { + Mockito.`when`(storage.retrieveString("com.auth0.id_token")).thenReturn("idToken") + Mockito.`when`(storage.retrieveString("com.auth0.access_token")).thenReturn("accessToken") + Mockito.`when`(storage.retrieveString("com.auth0.refresh_token")).thenReturn("refreshToken") + Mockito.`when`(storage.retrieveString("com.auth0.token_type")).thenReturn("DPoP") + val expirationTime = CredentialsMock.CURRENT_TIME_MS // expired + Mockito.`when`(storage.retrieveLong("com.auth0.expires_at")).thenReturn(expirationTime) + Mockito.`when`(storage.retrieveString("com.auth0.scope")).thenReturn("scope") + Mockito.`when`(storage.retrieveString("com.auth0.dpop_key_thumbprint")) + .thenReturn("old-thumbprint-from-previous-key") + whenever(mockDPoPKeyStore.hasKeyPair()).thenReturn(true) + whenever(mockDPoPKeyStore.getKeyPair()).thenReturn(Pair(fakePrivateKey, fakePublicKey)) + Mockito.doReturn(true).`when`(client).isDPoPEnabled + + manager.getCredentials(callback) + + verify(callback).onFailure(exceptionCaptor.capture()) + val exception = exceptionCaptor.firstValue + MatcherAssert.assertThat(exception, Is.`is`(CredentialsManagerException.DPOP_KEY_MISMATCH)) + } + + @Test + public fun shouldBackfillDPoPThumbprintForMigrationScenario() { + Mockito.`when`(storage.retrieveString("com.auth0.id_token")).thenReturn("idToken") + Mockito.`when`(storage.retrieveString("com.auth0.access_token")).thenReturn("accessToken") + Mockito.`when`(storage.retrieveString("com.auth0.refresh_token")).thenReturn("refreshToken") + Mockito.`when`(storage.retrieveString("com.auth0.token_type")).thenReturn("DPoP") + val expirationTime = CredentialsMock.CURRENT_TIME_MS // expired + Mockito.`when`(storage.retrieveLong("com.auth0.expires_at")).thenReturn(expirationTime) + Mockito.`when`(storage.retrieveString("com.auth0.scope")).thenReturn("scope") + // No stored thumbprint (migration scenario) + Mockito.`when`(storage.retrieveString("com.auth0.dpop_key_thumbprint")).thenReturn(null) + whenever(mockDPoPKeyStore.hasKeyPair()).thenReturn(true) + whenever(mockDPoPKeyStore.getKeyPair()).thenReturn(Pair(fakePrivateKey, fakePublicKey)) + Mockito.doReturn(true).`when`(client).isDPoPEnabled + Mockito.`when`(client.renewAuth("refreshToken")).thenReturn(request) + val newDate = Date(CredentialsMock.ONE_HOUR_AHEAD_MS + ONE_HOUR_SECONDS * 1000) + val jwtMock = mock() + Mockito.`when`(jwtMock.expiresAt).thenReturn(newDate) + Mockito.`when`(jwtDecoder.decode("newId")).thenReturn(jwtMock) + val renewedCredentials = + Credentials("newId", "newAccess", "DPoP", "newRefresh", newDate, "scope") + Mockito.`when`(request.execute()).thenReturn(renewedCredentials) + + manager.getCredentials(callback) + + // Verify thumbprint was backfilled during validation (and also stored again during saveCredentials after renewal) + verify(storage, Mockito.atLeastOnce()).store( + eq("com.auth0.dpop_key_thumbprint"), + ArgumentMatchers.anyString() + ) + verify(callback).onSuccess(credentialsCaptor.capture()) + } + + @Test + public fun shouldTriggerDPoPValidationViaStoredThumbprintEvenForBearerTokenType() { + Mockito.`when`(storage.retrieveString("com.auth0.id_token")).thenReturn("idToken") + Mockito.`when`(storage.retrieveString("com.auth0.access_token")).thenReturn("accessToken") + Mockito.`when`(storage.retrieveString("com.auth0.refresh_token")).thenReturn("refreshToken") + Mockito.`when`(storage.retrieveString("com.auth0.token_type")).thenReturn("Bearer") + val expirationTime = CredentialsMock.CURRENT_TIME_MS // expired + Mockito.`when`(storage.retrieveLong("com.auth0.expires_at")).thenReturn(expirationTime) + Mockito.`when`(storage.retrieveString("com.auth0.scope")).thenReturn("scope") + Mockito.`when`(storage.retrieveString("com.auth0.dpop_key_thumbprint")) + .thenReturn("some-thumbprint") + whenever(mockDPoPKeyStore.hasKeyPair()).thenReturn(false) + + manager.getCredentials(callback) + + verify(callback).onFailure(exceptionCaptor.capture()) + val exception = exceptionCaptor.firstValue + MatcherAssert.assertThat(exception, Is.`is`(CredentialsManagerException.DPOP_KEY_MISSING)) + } + + @Test + public fun shouldFailOnGetSsoCredentialsWithDPoPKeyMissingWhenKeyNotInKeyStore() { + Mockito.`when`(storage.retrieveString("com.auth0.refresh_token")).thenReturn("refreshToken") + Mockito.`when`(storage.retrieveString("com.auth0.token_type")).thenReturn("DPoP") + whenever(mockDPoPKeyStore.hasKeyPair()).thenReturn(false) + + manager.getSsoCredentials(ssoCallback) + + verify(ssoCallback).onFailure(exceptionCaptor.capture()) + val exception = exceptionCaptor.firstValue + MatcherAssert.assertThat(exception, Is.`is`(CredentialsManagerException.DPOP_KEY_MISSING)) + } + + @Test + public fun shouldFailOnGetApiCredentialsWithDPoPKeyMissingWhenKeyNotInKeyStore() { + val accessTokenExpiry = CredentialsMock.CURRENT_TIME_MS // expired + val apiCredentials = ApiCredentialsMock.create( + accessToken = "apiToken", + type = "DPoP", + expiresAt = Date(accessTokenExpiry), + scope = "read:data" + ) + Mockito.`when`(storage.retrieveString("audience::read:data")) + .thenReturn(gson.toJson(apiCredentials)) + Mockito.`when`(storage.retrieveString("com.auth0.refresh_token")).thenReturn("refreshToken") + whenever(mockDPoPKeyStore.hasKeyPair()).thenReturn(false) + + manager.getApiCredentials("audience", "read:data", callback = apiCredentialsCallback) + + verify(apiCredentialsCallback).onFailure(exceptionCaptor.capture()) + val exception = exceptionCaptor.firstValue + MatcherAssert.assertThat(exception, Is.`is`(CredentialsManagerException.DPOP_KEY_MISSING)) + } + + @Test + public fun shouldUseApiCredentialTypeForDPoPValidationInsteadOfBaseTokenType() { + val accessTokenExpiry = CredentialsMock.CURRENT_TIME_MS // expired + val apiCredentials = ApiCredentialsMock.create( + accessToken = "apiToken", + type = "DPoP", + expiresAt = Date(accessTokenExpiry), + scope = "read:data" + ) + Mockito.`when`(storage.retrieveString("audience::read:data")) + .thenReturn(gson.toJson(apiCredentials)) + Mockito.`when`(storage.retrieveString("com.auth0.refresh_token")).thenReturn("refreshToken") + Mockito.`when`(storage.retrieveString("com.auth0.token_type")).thenReturn("Bearer") + whenever(mockDPoPKeyStore.hasKeyPair()).thenReturn(false) + + manager.getApiCredentials("audience", "read:data", callback = apiCredentialsCallback) + + verify(apiCredentialsCallback).onFailure(exceptionCaptor.capture()) + val exception = exceptionCaptor.firstValue + MatcherAssert.assertThat(exception, Is.`is`(CredentialsManagerException.DPOP_KEY_MISSING)) + } + + @After + public fun tearDown() { + DPoPUtil.keyStore = DPoPKeyStore() } private fun prepareJwtDecoderMock(expiresAt: Date?) { diff --git a/auth0/src/test/java/com/auth0/android/authentication/storage/SecureCredentialsManagerTest.kt b/auth0/src/test/java/com/auth0/android/authentication/storage/SecureCredentialsManagerTest.kt index 078690cc3..10d495030 100644 --- a/auth0/src/test/java/com/auth0/android/authentication/storage/SecureCredentialsManagerTest.kt +++ b/auth0/src/test/java/com/auth0/android/authentication/storage/SecureCredentialsManagerTest.kt @@ -10,6 +10,10 @@ import com.auth0.android.NetworkErrorException import com.auth0.android.authentication.AuthenticationAPIClient import com.auth0.android.authentication.AuthenticationException import com.auth0.android.callback.Callback +import com.auth0.android.dpop.DPoPKeyStore +import com.auth0.android.dpop.DPoPUtil +import com.auth0.android.dpop.FakeECPrivateKey +import com.auth0.android.dpop.FakeECPublicKey import com.auth0.android.request.Request import com.auth0.android.request.internal.GsonProvider import com.auth0.android.request.internal.Jwt @@ -31,6 +35,7 @@ import com.nhaarman.mockitokotlin2.never import com.nhaarman.mockitokotlin2.times import com.nhaarman.mockitokotlin2.verify import com.nhaarman.mockitokotlin2.verifyNoMoreInteractions +import com.nhaarman.mockitokotlin2.whenever import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.runBlocking import kotlinx.coroutines.test.runTest @@ -38,6 +43,7 @@ import org.hamcrest.MatcherAssert import org.hamcrest.Matchers import org.hamcrest.core.Is import org.hamcrest.core.IsInstanceOf +import org.junit.After import org.junit.Assert import org.junit.Assert.assertThrows import org.junit.Before @@ -103,6 +109,10 @@ public class SecureCredentialsManagerTest { private lateinit var fragmentActivity: FragmentActivity + private lateinit var mockDPoPKeyStore: DPoPKeyStore + private val fakePublicKey = FakeECPublicKey() + private val fakePrivateKey = FakeECPrivateKey() + private val serialExecutor = Executor { runnable -> runnable.run() } private val credentialsCaptor: KArgumentCaptor = argumentCaptor() @@ -122,6 +132,9 @@ public class SecureCredentialsManagerTest { @Before public fun setUp() { MockitoAnnotations.openMocks(this) + mockDPoPKeyStore = mock() + DPoPUtil.keyStore = mockDPoPKeyStore + whenever(mockDPoPKeyStore.hasKeyPair()).thenReturn(false) val activity = Robolectric.buildActivity(Activity::class.java).create().start().resume().get() val activityContext = Mockito.spy(activity) @@ -612,6 +625,8 @@ public class SecureCredentialsManagerTest { verify(storage) .store("com.auth0.credentials_access_token_expires_at", sharedExpirationTime) verify(storage).store("com.auth0.credentials_can_refresh", true) + verify(storage).store("com.auth0.token_type", "type") + verify(storage).remove("com.auth0.dpop_key_thumbprint") verifyNoMoreInteractions(storage) val encodedJson = stringCaptor.firstValue MatcherAssert.assertThat(encodedJson, Is.`is`(Matchers.notNullValue())) @@ -647,6 +662,8 @@ public class SecureCredentialsManagerTest { verify(storage) .store("com.auth0.credentials_access_token_expires_at", accessTokenExpirationTime) verify(storage).store("com.auth0.credentials_can_refresh", true) + verify(storage).store("com.auth0.token_type", "type") + verify(storage).remove("com.auth0.dpop_key_thumbprint") verifyNoMoreInteractions(storage) val encodedJson = stringCaptor.firstValue MatcherAssert.assertThat(encodedJson, Is.`is`(Matchers.notNullValue())) @@ -686,6 +703,8 @@ public class SecureCredentialsManagerTest { verify(storage) .store("com.auth0.credentials_access_token_expires_at", accessTokenExpirationTime) verify(storage).store("com.auth0.credentials_can_refresh", true) + verify(storage).store("com.auth0.token_type", "type") + verify(storage).remove("com.auth0.dpop_key_thumbprint") verifyNoMoreInteractions(storage) val encodedJson = stringCaptor.firstValue MatcherAssert.assertThat(encodedJson, Is.`is`(Matchers.notNullValue())) @@ -725,6 +744,8 @@ public class SecureCredentialsManagerTest { verify(storage) .store("com.auth0.credentials_access_token_expires_at", expirationTime) verify(storage).store("com.auth0.credentials_can_refresh", false) + verify(storage).store("com.auth0.token_type", "type") + verify(storage).remove("com.auth0.dpop_key_thumbprint") verifyNoMoreInteractions(storage) val encodedJson = stringCaptor.firstValue MatcherAssert.assertThat(encodedJson, Is.`is`(Matchers.notNullValue())) @@ -1245,7 +1266,7 @@ public class SecureCredentialsManagerTest { verify(storage).store("com.auth0.credentials_expires_at", newDate.time) verify(storage).store("com.auth0.credentials_access_token_expires_at", newDate.time) verify(storage).store("com.auth0.credentials_can_refresh", true) - verify(storage, never()).remove(anyString()) + verify(storage).remove("com.auth0.dpop_key_thumbprint") // Verify the returned credentials are the latest val retrievedCredentials = credentialsCaptor.firstValue @@ -1369,7 +1390,7 @@ public class SecureCredentialsManagerTest { verify(storage).store("com.auth0.credentials_expires_at", newDate.time) verify(storage).store("com.auth0.credentials_access_token_expires_at", newDate.time) verify(storage).store("com.auth0.credentials_can_refresh", true) - verify(storage, never()).remove(anyString()) + verify(storage).remove("com.auth0.dpop_key_thumbprint") // Verify the returned credentials are the latest val retrievedCredentials = credentialsCaptor.firstValue @@ -1437,7 +1458,7 @@ public class SecureCredentialsManagerTest { verify(storage).store("com.auth0.credentials_expires_at", newDate.time) verify(storage).store("com.auth0.credentials_access_token_expires_at", newDate.time) verify(storage).store("com.auth0.credentials_can_refresh", true) - verify(storage, never()).remove(anyString()) + verify(storage).remove("com.auth0.dpop_key_thumbprint") // Verify the returned credentials are the latest val retrievedCredentials = credentialsCaptor.firstValue @@ -1507,7 +1528,7 @@ public class SecureCredentialsManagerTest { verify(storage).store("com.auth0.credentials_expires_at", newDate.time) verify(storage).store("com.auth0.credentials_access_token_expires_at", newDate.time) verify(storage).store("com.auth0.credentials_can_refresh", true) - verify(storage, never()).remove(anyString()) + verify(storage).remove("com.auth0.dpop_key_thumbprint") // Verify the returned credentials are the latest val retrievedCredentials = credentialsCaptor.firstValue @@ -1592,7 +1613,7 @@ public class SecureCredentialsManagerTest { .store(eq("com.auth0.credentials"), stringCaptor.capture()) verify(storage).store("com.auth0.credentials_expires_at", newDate.time) verify(storage).store("com.auth0.credentials_can_refresh", true) - verify(storage, never()).remove(anyString()) + verify(storage).remove("com.auth0.dpop_key_thumbprint") // Verify the returned credentials are the latest val retrievedCredentials = credentialsCaptor.firstValue @@ -1656,7 +1677,7 @@ public class SecureCredentialsManagerTest { .store(eq("com.auth0.credentials"), stringCaptor.capture()) verify(storage).store("com.auth0.credentials_expires_at", newDate.time) verify(storage).store("com.auth0.credentials_can_refresh", true) - verify(storage, never()).remove(anyString()) + verify(storage).remove("com.auth0.dpop_key_thumbprint") // Verify the returned credentials are the latest val retrievedCredentials = credentialsCaptor.firstValue @@ -2153,6 +2174,8 @@ public class SecureCredentialsManagerTest { verify(storage).remove("com.auth0.credentials_expires_at") verify(storage).remove("com.auth0.credentials_access_token_expires_at") verify(storage).remove("com.auth0.credentials_can_refresh") + verify(storage).remove("com.auth0.token_type") + verify(storage).remove("com.auth0.dpop_key_thumbprint") verifyNoMoreInteractions(storage) } @@ -3598,6 +3621,247 @@ public class SecureCredentialsManagerTest { } + @Test + public fun shouldStoreDPoPThumbprintWhenCredentialsTypeIsDPoP() { + val expirationTime = CredentialsMock.ONE_HOUR_AHEAD_MS + val credentials = CredentialsMock.create( + "idToken", "accessToken", "DPoP", "refreshToken", Date(expirationTime), "scope" + ) + val json = gson.toJson(credentials) + prepareJwtDecoderMock(Date(expirationTime)) + Mockito.`when`(crypto.encrypt(json.toByteArray())).thenReturn(json.toByteArray()) + whenever(mockDPoPKeyStore.hasKeyPair()).thenReturn(true) + whenever(mockDPoPKeyStore.getKeyPair()).thenReturn(Pair(fakePrivateKey, fakePublicKey)) + + manager.saveCredentials(credentials) + + verify(storage).store(eq("com.auth0.dpop_key_thumbprint"), anyString()) + verify(storage, never()).remove("com.auth0.dpop_key_thumbprint") + } + + @Test + public fun shouldStoreDPoPThumbprintWhenIsDPoPEnabledIsTrue() { + val expirationTime = CredentialsMock.ONE_HOUR_AHEAD_MS + val credentials = CredentialsMock.create( + "idToken", "accessToken", "Bearer", "refreshToken", Date(expirationTime), "scope" + ) + val json = gson.toJson(credentials) + prepareJwtDecoderMock(Date(expirationTime)) + Mockito.`when`(crypto.encrypt(json.toByteArray())).thenReturn(json.toByteArray()) + Mockito.doReturn(true).`when`(client).isDPoPEnabled + whenever(mockDPoPKeyStore.hasKeyPair()).thenReturn(true) + whenever(mockDPoPKeyStore.getKeyPair()).thenReturn(Pair(fakePrivateKey, fakePublicKey)) + + manager.saveCredentials(credentials) + + verify(storage).store(eq("com.auth0.dpop_key_thumbprint"), anyString()) + verify(storage, never()).remove("com.auth0.dpop_key_thumbprint") + } + + @Test + public fun shouldRemoveDPoPThumbprintForBearerCredentialsWithoutDPoP() { + val expirationTime = CredentialsMock.ONE_HOUR_AHEAD_MS + val credentials = CredentialsMock.create( + "idToken", "accessToken", "Bearer", "refreshToken", Date(expirationTime), "scope" + ) + val json = gson.toJson(credentials) + prepareJwtDecoderMock(Date(expirationTime)) + Mockito.`when`(crypto.encrypt(json.toByteArray())).thenReturn(json.toByteArray()) + + manager.saveCredentials(credentials) + + verify(storage).remove("com.auth0.dpop_key_thumbprint") + verify(storage, never()).store(eq("com.auth0.dpop_key_thumbprint"), anyString()) + } + + @Test + public fun shouldRemoveDPoPThumbprintWhenNoKeyPairExists() { + val expirationTime = CredentialsMock.ONE_HOUR_AHEAD_MS + val credentials = CredentialsMock.create( + "idToken", "accessToken", "DPoP", "refreshToken", Date(expirationTime), "scope" + ) + val json = gson.toJson(credentials) + prepareJwtDecoderMock(Date(expirationTime)) + Mockito.`when`(crypto.encrypt(json.toByteArray())).thenReturn(json.toByteArray()) + whenever(mockDPoPKeyStore.hasKeyPair()).thenReturn(false) + + manager.saveCredentials(credentials) + + verify(storage).remove("com.auth0.dpop_key_thumbprint") + verify(storage, never()).store(eq("com.auth0.dpop_key_thumbprint"), anyString()) + } + + + @Test + public fun shouldFailOnGetCredentialsWithDPoPKeyMissingWhenKeyNotInKeyStore() { + Mockito.`when`(localAuthenticationManager.authenticate()).then { + localAuthenticationManager.resultCallback.onSuccess(true) + } + val expiresAt = Date(CredentialsMock.CURRENT_TIME_MS) // expired + insertTestCredentials(true, true, true, expiresAt, "scope") + Mockito.`when`(storage.retrieveString("com.auth0.token_type")).thenReturn("DPoP") + whenever(mockDPoPKeyStore.hasKeyPair()).thenReturn(false) + + manager.getCredentials(callback) + + verify(callback).onFailure(exceptionCaptor.capture()) + val exception = exceptionCaptor.firstValue + MatcherAssert.assertThat(exception, Is.`is`(CredentialsManagerException.DPOP_KEY_MISSING)) + } + + @Test + public fun shouldFailOnGetCredentialsWithDPoPNotConfiguredWhenClientNotSetup() { + Mockito.`when`(localAuthenticationManager.authenticate()).then { + localAuthenticationManager.resultCallback.onSuccess(true) + } + val expiresAt = Date(CredentialsMock.CURRENT_TIME_MS) // expired + insertTestCredentials(true, true, true, expiresAt, "scope") + Mockito.`when`(storage.retrieveString("com.auth0.token_type")).thenReturn("DPoP") + whenever(mockDPoPKeyStore.hasKeyPair()).thenReturn(true) + + manager.getCredentials(callback) + + verify(callback).onFailure(exceptionCaptor.capture()) + val exception = exceptionCaptor.firstValue + MatcherAssert.assertThat(exception, Is.`is`(CredentialsManagerException.DPOP_NOT_CONFIGURED)) + } + + @Test + public fun shouldFailOnGetCredentialsWithDPoPKeyMismatchWhenThumbprintsDontMatch() { + Mockito.`when`(localAuthenticationManager.authenticate()).then { + localAuthenticationManager.resultCallback.onSuccess(true) + } + val expiresAt = Date(CredentialsMock.CURRENT_TIME_MS) // expired + insertTestCredentials(true, true, true, expiresAt, "scope") + Mockito.`when`(storage.retrieveString("com.auth0.token_type")).thenReturn("DPoP") + Mockito.`when`(storage.retrieveString("com.auth0.dpop_key_thumbprint")) + .thenReturn("old-thumbprint-from-previous-key") + whenever(mockDPoPKeyStore.hasKeyPair()).thenReturn(true) + whenever(mockDPoPKeyStore.getKeyPair()).thenReturn(Pair(fakePrivateKey, fakePublicKey)) + Mockito.doReturn(true).`when`(client).isDPoPEnabled + + manager.getCredentials(callback) + + verify(callback).onFailure(exceptionCaptor.capture()) + val exception = exceptionCaptor.firstValue + MatcherAssert.assertThat(exception, Is.`is`(CredentialsManagerException.DPOP_KEY_MISMATCH)) + } + + @Test + public fun shouldBackfillDPoPThumbprintForMigrationScenario() { + val expiresAt = Date(CredentialsMock.CURRENT_TIME_MS) // expired + insertTestCredentials(true, true, true, expiresAt, "scope") + Mockito.`when`(storage.retrieveString("com.auth0.token_type")).thenReturn("DPoP") + // No stored thumbprint (migration scenario) + Mockito.`when`(storage.retrieveString("com.auth0.dpop_key_thumbprint")).thenReturn(null) + whenever(mockDPoPKeyStore.hasKeyPair()).thenReturn(true) + whenever(mockDPoPKeyStore.getKeyPair()).thenReturn(Pair(fakePrivateKey, fakePublicKey)) + Mockito.doReturn(true).`when`(client).isDPoPEnabled + Mockito.`when`(client.renewAuth("refreshToken")).thenReturn(request) + val newDate = Date(CredentialsMock.ONE_HOUR_AHEAD_MS + ONE_HOUR_SECONDS * 1000) + val jwtMock = mock() + Mockito.`when`(jwtMock.expiresAt).thenReturn(newDate) + Mockito.`when`(jwtDecoder.decode("newId")).thenReturn(jwtMock) + val renewedCredentials = + Credentials("newId", "newAccess", "DPoP", "newRefresh", newDate, "scope") + Mockito.`when`(request.execute()).thenReturn(renewedCredentials) + val expectedJson = gson.toJson(renewedCredentials) + Mockito.`when`(crypto.encrypt(any())).thenReturn(expectedJson.toByteArray()) + + manager.continueGetCredentials(null, 0, emptyMap(), emptyMap(), false, callback) + + // Verify thumbprint was backfilled during validation (and also stored again during saveCredentials after renewal) + verify(storage, Mockito.atLeastOnce()).store(eq("com.auth0.dpop_key_thumbprint"), anyString()) + verify(callback).onSuccess(credentialsCaptor.capture()) + } + + @Test + public fun shouldTriggerDPoPValidationViaStoredThumbprintEvenForBearerTokenType() { + val expiresAt = Date(CredentialsMock.CURRENT_TIME_MS) // expired + insertTestCredentials(true, true, true, expiresAt, "scope") + Mockito.`when`(storage.retrieveString("com.auth0.token_type")).thenReturn("Bearer") + // Thumbprint exists (DPoP was used, but token_type is Bearer — RFC 9449 edge case) + Mockito.`when`(storage.retrieveString("com.auth0.dpop_key_thumbprint")) + .thenReturn("some-thumbprint") + whenever(mockDPoPKeyStore.hasKeyPair()).thenReturn(false) + + manager.continueGetCredentials(null, 0, emptyMap(), emptyMap(), false, callback) + + verify(callback).onFailure(exceptionCaptor.capture()) + val exception = exceptionCaptor.firstValue + MatcherAssert.assertThat(exception, Is.`is`(CredentialsManagerException.DPOP_KEY_MISSING)) + } + + @Test + public fun shouldFailOnGetSsoCredentialsWithDPoPKeyMissingWhenKeyNotInKeyStore() { + val expiresAt = Date(CredentialsMock.ONE_HOUR_AHEAD_MS) + insertTestCredentials(true, true, true, expiresAt, "scope") + Mockito.`when`(storage.retrieveString("com.auth0.token_type")).thenReturn("DPoP") + whenever(mockDPoPKeyStore.hasKeyPair()).thenReturn(false) + + manager.getSsoCredentials(ssoCallback) + + verify(ssoCallback).onFailure(exceptionCaptor.capture()) + val exception = exceptionCaptor.firstValue + MatcherAssert.assertThat(exception, Is.`is`(CredentialsManagerException.DPOP_KEY_MISSING)) + } + + @Test + public fun shouldFailOnGetApiCredentialsWithDPoPKeyMissingWhenKeyNotInKeyStore() { + val accessTokenExpiry = CredentialsMock.CURRENT_TIME_MS // expired + val apiCredentials = ApiCredentialsMock.create( + accessToken = "apiToken", + type = "DPoP", + expiresAt = Date(accessTokenExpiry), + scope = "read:data" + ) + val storedJson = gson.toJson(apiCredentials) + val encoded = String(Base64.encode(storedJson.toByteArray(), Base64.DEFAULT)) + Mockito.`when`(crypto.decrypt(storedJson.toByteArray())) + .thenReturn(storedJson.toByteArray()) + Mockito.`when`(storage.retrieveString("audience::read:data")).thenReturn(encoded) + val expiresAt = Date(CredentialsMock.ONE_HOUR_AHEAD_MS) + insertTestCredentials(true, true, true, expiresAt, "scope") + whenever(mockDPoPKeyStore.hasKeyPair()).thenReturn(false) + + manager.continueGetApiCredentials("audience", "read:data", 0, emptyMap(), emptyMap(), apiCredentialsCallback) + + verify(apiCredentialsCallback).onFailure(exceptionCaptor.capture()) + val exception = exceptionCaptor.firstValue + MatcherAssert.assertThat(exception, Is.`is`(CredentialsManagerException.DPOP_KEY_MISSING)) + } + + @Test + public fun shouldUseApiCredentialTypeForDPoPValidationInsteadOfBaseTokenType() { + val accessTokenExpiry = CredentialsMock.CURRENT_TIME_MS // expired + val apiCredentials = ApiCredentialsMock.create( + accessToken = "apiToken", + type = "DPoP", + expiresAt = Date(accessTokenExpiry), + scope = "read:data" + ) + val storedJson = gson.toJson(apiCredentials) + val encoded = String(Base64.encode(storedJson.toByteArray(), Base64.DEFAULT)) + Mockito.`when`(crypto.decrypt(storedJson.toByteArray())) + .thenReturn(storedJson.toByteArray()) + Mockito.`when`(storage.retrieveString("audience::read:data")).thenReturn(encoded) + Mockito.`when`(storage.retrieveString("com.auth0.token_type")).thenReturn("Bearer") + val expiresAt = Date(CredentialsMock.ONE_HOUR_AHEAD_MS) + insertTestCredentials(true, true, true, expiresAt, "scope") + whenever(mockDPoPKeyStore.hasKeyPair()).thenReturn(false) + + manager.continueGetApiCredentials("audience", "read:data", 0, emptyMap(), emptyMap(), apiCredentialsCallback) + + verify(apiCredentialsCallback).onFailure(exceptionCaptor.capture()) + val exception = exceptionCaptor.firstValue + MatcherAssert.assertThat(exception, Is.`is`(CredentialsManagerException.DPOP_KEY_MISSING)) + } + + @After + public fun tearDown() { + DPoPUtil.keyStore = DPoPKeyStore() + } + private fun prepareJwtDecoderMock(expiresAt: Date?) { val jwtMock = mock() Mockito.`when`(jwtMock.expiresAt).thenReturn(expiresAt)