diff --git a/.github/actions/rl-scanner/action.yml b/.github/actions/rl-scanner/action.yml index 9fd39bc9..ca6a2c80 100644 --- a/.github/actions/rl-scanner/action.yml +++ b/.github/actions/rl-scanner/action.yml @@ -55,7 +55,7 @@ runs: --repository "${{ github.repository }}" \ --commit "${{ github.sha }}" \ --build-env "github_actions" \ - --suppress_output + --suppress-output # Check the outcome of the scanner if [ $? -ne 0 ]; then diff --git a/.version b/.version index 77fdc6bb..12566ed7 100644 --- a/.version +++ b/.version @@ -1 +1 @@ -3.13.0 \ No newline at end of file +3.14.0 \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index e3c92be3..2d25046e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,16 @@ # Change Log +## [3.14.0](https://github.com/auth0/Auth0.Android/tree/3.14.0) (2026-03-11) +[Full Changelog](https://github.com/auth0/Auth0.Android/compare/3.13.0...3.14.0) + +**Deprecated** +- refactor : Deprecate the existing MFA APIs in `AuthenticationAPIClient` [\#932](https://github.com/auth0/Auth0.Android/pull/932) ([pmathew92](https://github.com/pmathew92)) +- refactor : Deprecated the UsersAPIClient [\#930](https://github.com/auth0/Auth0.Android/pull/930) ([pmathew92](https://github.com/pmathew92)) + +**Fixed** +- fix: Handle ProviderException in PKCS1→OAEP key migration to prevent saveCredentials() crash [\#924](https://github.com/auth0/Auth0.Android/pull/924) ([utkrishtsahu](https://github.com/utkrishtsahu)) +- fix : Added the missing user agent to MyAccount and MFAApiClient [\#926](https://github.com/auth0/Auth0.Android/pull/926) ([pmathew92](https://github.com/pmathew92)) + ## [3.13.0](https://github.com/auth0/Auth0.Android/tree/3.13.0) (2026-02-06) [Full Changelog](https://github.com/auth0/Auth0.Android/compare/3.12.2...3.13.0) diff --git a/EXAMPLES.md b/EXAMPLES.md index 49d2f181..6de7e987 100644 --- a/EXAMPLES.md +++ b/EXAMPLES.md @@ -12,7 +12,7 @@ - [Specify a Custom Logout URL](#specify-a-custom-logout-url) - [Trusted Web Activity](#trusted-web-activity) - [Ephemeral Browsing [Experimental]](#ephemeral-browsing-experimental) - - [DPoP [EA]](#dpop-ea) + - [DPoP](#dpop) - [Authentication API](#authentication-api) - [Login with database connection](#login-with-database-connection) - [Login using MFA with One Time Password code](#login-using-mfa-with-one-time-password-code) @@ -31,7 +31,7 @@ - [Get user information](#get-user-information) - [Custom Token Exchange](#custom-token-exchange) - [Native to Web SSO login](#native-to-web-sso-login) - - [DPoP [EA]](#dpop-ea-1) + - [DPoP](#dpop-1) - [My Account API](#my-account-api) - [Enroll a new passkey](#enroll-a-new-passkey) - [Get Available Factors](#get-available-factors) @@ -265,10 +265,7 @@ WebAuthProvider.login(account) ``` -## DPoP [EA] - -> [!NOTE] -> This feature is currently available in [Early Access](https://auth0.com/docs/troubleshoot/product-lifecycle/product-release-stages#early-access). Please reach out to Auth0 support to get it enabled for your tenant. +## DPoP [DPoP](https://www.rfc-editor.org/rfc/rfc9449.html) (Demonstrating Proof of Possession) is an application-level mechanism for sender-constraining OAuth 2.0 access and refresh tokens by proving that the app is in possession of a certain private key. You can enable it by calling the `useDPoP(context)` method on the login Builder. @@ -1643,10 +1640,7 @@ authentication ``` -## DPoP [EA] - -> [!NOTE] -> This feature is currently available in [Early Access](https://auth0.com/docs/troubleshoot/product-lifecycle/product-release-stages#early-access). Please reach out to Auth0 support to get it enabled for your tenant. +## DPoP [DPoP](https://www.rfc-editor.org/rfc/rfc9449.html) (Demonstrating Proof of Possession) is an application-level mechanism for sender-constraining OAuth 2.0 access and refresh tokens by proving that the app is in possession of a certain private key. You can enable it by calling the `useDPoP(context: Context)` method. This ensures that DPoP proofs are generated for requests made through the AuthenticationAPI client. 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 31237730..572ecc51 100755 --- a/auth0/src/main/java/com/auth0/android/authentication/AuthenticationAPIClient.kt +++ b/auth0/src/main/java/com/auth0/android/authentication/AuthenticationAPIClient.kt @@ -174,6 +174,7 @@ public class AuthenticationAPIClient @VisibleForTesting(otherwise = VisibleForTe return loginWithToken(requestParameters) } + /** * Log in a user using the One Time Password code after they have received the 'mfa_required' error. * The MFA token tells the server the username or email, password, and realm values sent on the first request. @@ -196,6 +197,10 @@ public class AuthenticationAPIClient @VisibleForTesting(otherwise = VisibleForTe * MFA application such as Google Authenticator or Guardian. * @return a request to configure and start that will yield [Credentials] */ + @Deprecated( + message = "loginWithOTP is deprecated and will be removed in the next major version of the SDK. Use the APIs in the [com.auth0.android.authentication.mfa.MfaApiClient] class instead.", + level = DeprecationLevel.WARNING + ) public fun loginWithOTP(mfaToken: String, otp: String): AuthenticationRequest { val parameters = ParameterBuilder.newBuilder() .setGrantType(ParameterBuilder.GRANT_TYPE_MFA_OTP) @@ -409,6 +414,10 @@ public class AuthenticationAPIClient @VisibleForTesting(otherwise = VisibleForTe * This is usually an OTP-like code delivered as part of the challenge message. * @return a request to configure and start that will yield [Credentials] */ + @Deprecated( + message = "loginWithOOB is deprecated and will be removed in the next major version of the SDK. Use the APIs in the [com.auth0.android.authentication.mfa.MfaApiClient] class instead.", + level = DeprecationLevel.WARNING + ) public fun loginWithOOB( mfaToken: String, oobCode: String, @@ -445,6 +454,10 @@ public class AuthenticationAPIClient @VisibleForTesting(otherwise = VisibleForTe * @return a request to configure and start that will yield [Credentials]. It might also include a [recoveryCode] field, * which your application must display to the end-user to be stored securely for future use. */ + @Deprecated( + message = "loginWithRecoveryCode is deprecated and will be removed in the next major version of the SDK. Use the APIs in the [com.auth0.android.authentication.mfa.MfaApiClient] class instead.", + level = DeprecationLevel.WARNING + ) public fun loginWithRecoveryCode( mfaToken: String, recoveryCode: String @@ -478,6 +491,10 @@ public class AuthenticationAPIClient @VisibleForTesting(otherwise = VisibleForTe * @param authenticatorId The ID of the authenticator to challenge. * @return a request to configure and start that will yield [Challenge] */ + @Deprecated( + message = "multifactorChallenge is deprecated and will be removed in the next major version of the SDK. Use the APIs in the [com.auth0.android.authentication.mfa.MfaApiClient] class instead.", + level = DeprecationLevel.WARNING + ) public fun multifactorChallenge( mfaToken: String, challengeType: String? = null, diff --git a/auth0/src/main/java/com/auth0/android/authentication/mfa/MfaApiClient.kt b/auth0/src/main/java/com/auth0/android/authentication/mfa/MfaApiClient.kt index c6087705..d22503a6 100644 --- a/auth0/src/main/java/com/auth0/android/authentication/mfa/MfaApiClient.kt +++ b/auth0/src/main/java/com/auth0/android/authentication/mfa/MfaApiClient.kt @@ -4,7 +4,10 @@ import androidx.annotation.VisibleForTesting import com.auth0.android.Auth0 import com.auth0.android.Auth0Exception import com.auth0.android.authentication.ParameterBuilder -import com.auth0.android.authentication.mfa.MfaException.* +import com.auth0.android.authentication.mfa.MfaException.MfaChallengeException +import com.auth0.android.authentication.mfa.MfaException.MfaEnrollmentException +import com.auth0.android.authentication.mfa.MfaException.MfaListAuthenticatorsException +import com.auth0.android.authentication.mfa.MfaException.MfaVerifyException import com.auth0.android.request.ErrorAdapter import com.auth0.android.request.JsonAdapter import com.auth0.android.request.Request @@ -58,19 +61,27 @@ public class MfaApiClient @VisibleForTesting(otherwise = VisibleForTesting.PRIVA // Specialized factories for MFA-specific errors private val listAuthenticatorsFactory: RequestFactory by lazy { - RequestFactory(auth0.networkingClient, createListAuthenticatorsErrorAdapter()) + RequestFactory(auth0.networkingClient, createListAuthenticatorsErrorAdapter()).apply { + setAuth0ClientInfo(auth0.auth0UserAgent.value) + } } private val enrollmentFactory: RequestFactory by lazy { - RequestFactory(auth0.networkingClient, createEnrollmentErrorAdapter()) + RequestFactory(auth0.networkingClient, createEnrollmentErrorAdapter()).apply { + setAuth0ClientInfo(auth0.auth0UserAgent.value) + } } private val challengeFactory: RequestFactory by lazy { - RequestFactory(auth0.networkingClient, createChallengeErrorAdapter()) + RequestFactory(auth0.networkingClient, createChallengeErrorAdapter()).apply { + setAuth0ClientInfo(auth0.auth0UserAgent.value) + } } private val verifyFactory: RequestFactory by lazy { - RequestFactory(auth0.networkingClient, createVerifyErrorAdapter()) + RequestFactory(auth0.networkingClient, createVerifyErrorAdapter()).apply { + setAuth0ClientInfo(auth0.auth0UserAgent.value) + } } /** @@ -175,7 +186,11 @@ public class MfaApiClient @VisibleForTesting(otherwise = VisibleForTesting.PRIVA */ public fun enroll(type: MfaEnrollmentType): Request { return when (type) { - is MfaEnrollmentType.Phone -> enrollOob(oobChannel = "sms", phoneNumber = type.phoneNumber) + is MfaEnrollmentType.Phone -> enrollOob( + oobChannel = "sms", + phoneNumber = type.phoneNumber + ) + is MfaEnrollmentType.Email -> enrollOob(oobChannel = "email", email = type.email) is MfaEnrollmentType.Otp -> enrollOtpInternal() is MfaEnrollmentType.Push -> enrollOob(oobChannel = "auth0") @@ -228,7 +243,6 @@ public class MfaApiClient @VisibleForTesting(otherwise = VisibleForTesting.PRIVA } - /** * Verifies an MFA challenge using the specified verification type. * @@ -290,7 +304,7 @@ public class MfaApiClient @VisibleForTesting(otherwise = VisibleForTesting.PRIVA return object : JsonAdapter> { override fun fromJson(reader: Reader, metadata: Map): List { val allAuthenticators = baseAdapter.fromJson(reader, metadata) - + return allAuthenticators.filter { authenticator -> matchesFactorType(authenticator, factorsAllowed) } @@ -313,9 +327,12 @@ public class MfaApiClient @VisibleForTesting(otherwise = VisibleForTesting.PRIVA * @param factorsAllowed List of allowed factor types * @return true if the authenticator matches any allowed factor type */ - private fun matchesFactorType(authenticator: Authenticator, factorsAllowed: List): Boolean { + private fun matchesFactorType( + authenticator: Authenticator, + factorsAllowed: List + ): Boolean { val effectiveType = getEffectiveType(authenticator) - + return factorsAllowed.any { factor -> val normalizedFactor = factor.lowercase(java.util.Locale.ROOT) when (normalizedFactor) { @@ -325,7 +342,7 @@ public class MfaApiClient @VisibleForTesting(otherwise = VisibleForTesting.PRIVA "oob" -> authenticator.authenticatorType == "oob" || authenticator.type == "oob" "recovery-code" -> effectiveType == "recovery-code" "push-notification" -> effectiveType == "push-notification" - else -> effectiveType == normalizedFactor || + else -> effectiveType == normalizedFactor || authenticator.authenticatorType?.lowercase(java.util.Locale.ROOT) == normalizedFactor || authenticator.type.lowercase(java.util.Locale.ROOT) == normalizedFactor } @@ -370,7 +387,7 @@ public class MfaApiClient @VisibleForTesting(otherwise = VisibleForTesting.PRIVA .addHeader(HEADER_AUTHORIZATION, "Bearer $mfaToken") .addParameter(AUTHENTICATOR_TYPES_KEY, listOf("oob")) .addParameter(OOB_CHANNELS_KEY, listOf(oobChannel)) - + if (phoneNumber != null) { request.addParameter(PHONE_NUMBER_KEY, phoneNumber) } @@ -411,7 +428,7 @@ public class MfaApiClient @VisibleForTesting(otherwise = VisibleForTesting.PRIVA .setGrantType(GRANT_TYPE_MFA_OOB) .set(MFA_TOKEN_KEY, mfaToken) .set(OUT_OF_BAND_CODE_KEY, oobCode) - + if (bindingCode != null) { parametersBuilder.set(BINDING_CODE_KEY, bindingCode) } @@ -465,7 +482,6 @@ public class MfaApiClient @VisibleForTesting(otherwise = VisibleForTesting.PRIVA } - /** * Creates error adapter for getAuthenticators() operations. */ @@ -643,6 +659,7 @@ public class MfaApiClient @VisibleForTesting(otherwise = VisibleForTesting.PRIVA private const val RECOVERY_CODE_KEY = "recovery_code" private const val GRANT_TYPE_MFA_OTP = "http://auth0.com/oauth/grant-type/mfa-otp" private const val GRANT_TYPE_MFA_OOB = "http://auth0.com/oauth/grant-type/mfa-oob" - private const val GRANT_TYPE_MFA_RECOVERY_CODE = "http://auth0.com/oauth/grant-type/mfa-recovery-code" + private const val GRANT_TYPE_MFA_RECOVERY_CODE = + "http://auth0.com/oauth/grant-type/mfa-recovery-code" } } diff --git a/auth0/src/main/java/com/auth0/android/authentication/storage/CryptoUtil.java b/auth0/src/main/java/com/auth0/android/authentication/storage/CryptoUtil.java index e0b175e8..4ac74546 100644 --- a/auth0/src/main/java/com/auth0/android/authentication/storage/CryptoUtil.java +++ b/auth0/src/main/java/com/auth0/android/authentication/storage/CryptoUtil.java @@ -343,10 +343,9 @@ byte[] RSADecrypt(byte[] encryptedInput) throws IncompatibleDeviceException, Cry Cipher cipher = Cipher.getInstance(RSA_TRANSFORMATION); cipher.init(Cipher.DECRYPT_MODE, privateKey, OAEP_SPEC); return cipher.doFinal(encryptedInput); - } catch (NoSuchAlgorithmException | NoSuchPaddingException | InvalidKeyException | InvalidAlgorithmParameterException e) { + } catch (NoSuchAlgorithmException | NoSuchPaddingException | InvalidKeyException + | InvalidAlgorithmParameterException e) { /* - * This exceptions are safe to be ignored: - * * - NoSuchPaddingException: * Thrown if PKCS1Padding is not available. Was introduced in API 1. * - NoSuchAlgorithmException: @@ -361,6 +360,24 @@ byte[] RSADecrypt(byte[] encryptedInput) throws IncompatibleDeviceException, Cry */ Log.e(TAG, "The device can't decrypt input using a RSA Key.", e); throw new IncompatibleDeviceException(e); + } catch (ProviderException e) { + /* + * - ProviderException: + * Thrown on Android 12+ (API 31+, Keystore2) when the RSA key's padding + * restriction does not match the cipher transformation. For example, an RSA + * key generated with ENCRYPTION_PADDING_RSA_PKCS1 will trigger this when + * initialised with an OAEPWithSHA-1AndMGF1Padding cipher. On API 23-30 the + * same condition surfaces as InvalidKeyException. + * + * This is NOT a device-level incompatibility -- the key can be deleted and + * regenerated with the correct padding. Wrapping as CryptoException (rather + * than IncompatibleDeviceException) ensures the caller falls through to key + * cleanup and regeneration instead of permanently blocking the user. + */ + Log.e(TAG, "RSA key padding mismatch detected (Android 12+ Keystore2).", e); + deleteAESKeys(); + throw new CryptoException( + "The RSA key's padding mode is incompatible with the current cipher.", e); } catch (IllegalArgumentException | IllegalBlockSizeException | BadPaddingException e) { /* * Any of this exceptions mean the encrypted input is somehow corrupted and cannot be recovered. @@ -394,10 +411,9 @@ byte[] RSAEncrypt(byte[] decryptedInput) throws IncompatibleDeviceException, Cry Cipher cipher = Cipher.getInstance(RSA_TRANSFORMATION); cipher.init(Cipher.ENCRYPT_MODE, certificate.getPublicKey(), OAEP_SPEC); return cipher.doFinal(decryptedInput); - } catch (NoSuchAlgorithmException | NoSuchPaddingException | InvalidKeyException | InvalidAlgorithmParameterException e) { + } catch (NoSuchAlgorithmException | NoSuchPaddingException | InvalidKeyException + | InvalidAlgorithmParameterException e) { /* - * This exceptions are safe to be ignored: - * * - NoSuchPaddingException: * Thrown if PKCS1Padding is not available. Was introduced in API 1. * - NoSuchAlgorithmException: @@ -412,6 +428,24 @@ byte[] RSAEncrypt(byte[] decryptedInput) throws IncompatibleDeviceException, Cry */ Log.e(TAG, "The device can't encrypt input using a RSA Key.", e); throw new IncompatibleDeviceException(e); + } catch (ProviderException e) { + /* + * - ProviderException: + * Thrown on Android 12+ (API 31+, Keystore2) when the RSA key's padding + * restriction does not match the cipher transformation. For example, an RSA + * key generated with ENCRYPTION_PADDING_RSA_PKCS1 will trigger this when + * initialised with an OAEPWithSHA-1AndMGF1Padding cipher. On API 23-30 the + * same condition surfaces as InvalidKeyException. + * + * This is NOT a device-level incompatibility -- the key can be deleted and + * regenerated with the correct padding. Wrapping as CryptoException (rather + * than IncompatibleDeviceException) ensures the caller falls through to key + * cleanup and regeneration instead of permanently blocking the user. + */ + Log.e(TAG, "RSA key padding mismatch detected (Android 12+ Keystore2).", e); + deleteAESKeys(); + throw new CryptoException( + "The RSA key's padding mode is incompatible with the current cipher.", e); } catch (IllegalBlockSizeException | BadPaddingException e) { /* * They really should not be thrown at all since padding is requested in the transformation. @@ -467,10 +501,12 @@ private byte[] attemptPKCS1Migration(byte[] encryptedAESBytes) { } catch (BadPaddingException | IllegalBlockSizeException e) { Log.e(TAG, "PKCS1 decryption failed. Data may be corrupted.", e); - } catch (KeyStoreException | CertificateException | IOException | + } catch (KeyStoreException | CertificateException | IOException | NoSuchAlgorithmException | UnrecoverableEntryException | NoSuchPaddingException | InvalidKeyException e) { Log.e(TAG, "Migration failed due to key access error.", e); + } catch (ProviderException e) { + Log.e(TAG, "PKCS1 migration failed: key padding incompatible (Android 12+ Keystore2).", e); } catch (CryptoException e) { Log.e(TAG, "Failed to re-encrypt AES key with OAEP.", e); } @@ -593,7 +629,9 @@ private byte[] tryMigrateLegacyAESKey() { KeyStore.PrivateKeyEntry rsaKeyEntry = getRSAKeyEntry(); byte[] decryptedAESKey = RSADecryptLegacyPKCS1(encryptedOldAESBytes, rsaKeyEntry.getPrivateKey()); - + + deleteRSAKeys(); + // Re-encrypt with OAEP and store at new location byte[] encryptedAESWithOAEP = RSAEncrypt(decryptedAESKey); String newEncodedEncryptedAES = new String(Base64.encode(encryptedAESWithOAEP, Base64.DEFAULT), StandardCharsets.UTF_8); @@ -603,7 +641,8 @@ private byte[] tryMigrateLegacyAESKey() { Log.d(TAG, "Legacy AES key migrated successfully"); return decryptedAESKey; } catch (CryptoException | NoSuchPaddingException | NoSuchAlgorithmException | InvalidKeyException | - BadPaddingException | IllegalBlockSizeException | IllegalArgumentException e) { + BadPaddingException | IllegalBlockSizeException | IllegalArgumentException | + ProviderException e) { Log.e(TAG, "Could not migrate legacy AES key. Will generate new key.", e); deleteAESKeys(); return null; @@ -632,8 +671,11 @@ private byte[] generateNewAESKey() throws IncompatibleDeviceException, CryptoExc } catch (NoSuchAlgorithmException e) { Log.e(TAG, "AES algorithm not available.", e); throw new IncompatibleDeviceException(e); + } catch (IncompatibleDeviceException e) { + deleteRSAKeys(); + deleteAESKeys(); + throw e; } catch (CryptoException e) { - // Re-throw CryptoException and its subclasses (including IncompatibleDeviceException) throw e; } catch (Exception e) { Log.e(TAG, "Unexpected error while creating new AES key.", e); diff --git a/auth0/src/main/java/com/auth0/android/management/ManagementException.kt b/auth0/src/main/java/com/auth0/android/management/ManagementException.kt index c74da094..96cc52d9 100644 --- a/auth0/src/main/java/com/auth0/android/management/ManagementException.kt +++ b/auth0/src/main/java/com/auth0/android/management/ManagementException.kt @@ -3,6 +3,11 @@ package com.auth0.android.management import com.auth0.android.Auth0Exception import com.auth0.android.NetworkErrorException + +@Deprecated( + """ManagementException is deprecated and will be removed in the next major version of the SDK. """, + level = DeprecationLevel.WARNING +) public class ManagementException @JvmOverloads constructor( message: String, exception: Auth0Exception? = null diff --git a/auth0/src/main/java/com/auth0/android/management/UsersAPIClient.kt b/auth0/src/main/java/com/auth0/android/management/UsersAPIClient.kt index 43f23af4..4967e364 100755 --- a/auth0/src/main/java/com/auth0/android/management/UsersAPIClient.kt +++ b/auth0/src/main/java/com/auth0/android/management/UsersAPIClient.kt @@ -32,6 +32,11 @@ import java.io.Reader * * @see [Auth API docs](https://auth0.com/docs/api/management/v2) */ + +@Deprecated( + """UsersAPIClient is deprecated and will be removed in the next major version of the SDK.""", + level = DeprecationLevel.WARNING +) public class UsersAPIClient @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) internal constructor( private val auth0: Auth0, private val factory: RequestFactory, diff --git a/auth0/src/main/java/com/auth0/android/myaccount/MyAccountAPIClient.kt b/auth0/src/main/java/com/auth0/android/myaccount/MyAccountAPIClient.kt index fa5c2328..a8b9447c 100644 --- a/auth0/src/main/java/com/auth0/android/myaccount/MyAccountAPIClient.kt +++ b/auth0/src/main/java/com/auth0/android/myaccount/MyAccountAPIClient.kt @@ -281,7 +281,10 @@ public class MyAccountAPIClient @VisibleForTesting(otherwise = VisibleForTesting val url = getDomainUrlBuilder().addPathSegment(AUTHENTICATION_METHODS).build() val listAdapter = object : JsonAdapter> { - override fun fromJson(reader: Reader, metadata: Map): List { + override fun fromJson( + reader: Reader, + metadata: Map + ): List { val container = gson.fromJson(reader, AuthenticationMethods::class.java) return container.authenticationMethods } @@ -848,5 +851,9 @@ public class MyAccountAPIClient @VisibleForTesting(otherwise = VisibleForTesting } } } + + init { + factory.setAuth0ClientInfo(auth0.auth0UserAgent.value) + } } diff --git a/auth0/src/test/java/com/auth0/android/authentication/MfaApiClientTest.kt b/auth0/src/test/java/com/auth0/android/authentication/MfaApiClientTest.kt index 8fec80b5..acd415cd 100644 --- a/auth0/src/test/java/com/auth0/android/authentication/MfaApiClientTest.kt +++ b/auth0/src/test/java/com/auth0/android/authentication/MfaApiClientTest.kt @@ -3,16 +3,17 @@ package com.auth0.android.authentication import com.auth0.android.Auth0 import com.auth0.android.authentication.mfa.MfaApiClient import com.auth0.android.authentication.mfa.MfaEnrollmentType +import com.auth0.android.authentication.mfa.MfaException.MfaChallengeException +import com.auth0.android.authentication.mfa.MfaException.MfaEnrollmentException +import com.auth0.android.authentication.mfa.MfaException.MfaListAuthenticatorsException +import com.auth0.android.authentication.mfa.MfaException.MfaVerifyException import com.auth0.android.authentication.mfa.MfaVerificationType -import com.auth0.android.authentication.mfa.MfaException.* import com.auth0.android.request.internal.ThreadSwitcherShadow import com.auth0.android.result.Authenticator import com.auth0.android.result.Challenge import com.auth0.android.result.Credentials import com.auth0.android.result.EnrollmentChallenge -import com.auth0.android.result.MfaEnrollmentChallenge import com.auth0.android.result.TotpEnrollmentChallenge -import com.auth0.android.util.CallbackMatcher import com.auth0.android.util.MockCallback import com.auth0.android.util.SSLTestUtils import com.google.gson.Gson @@ -24,7 +25,12 @@ import okhttp3.mockwebserver.MockResponse import okhttp3.mockwebserver.MockWebServer import okhttp3.mockwebserver.RecordedRequest import org.hamcrest.MatcherAssert.assertThat -import org.hamcrest.Matchers.* +import org.hamcrest.Matchers.containsString +import org.hamcrest.Matchers.hasSize +import org.hamcrest.Matchers.instanceOf +import org.hamcrest.Matchers.`is` +import org.hamcrest.Matchers.notNullValue +import org.hamcrest.Matchers.nullValue import org.junit.After import org.junit.Assert.assertThrows import org.junit.Before @@ -69,7 +75,11 @@ public class MfaApiClientTest { ) } - private fun enqueueErrorResponse(error: String, description: String, statusCode: Int = 400): Unit { + private fun enqueueErrorResponse( + error: String, + description: String, + statusCode: Int = 400 + ): Unit { val json = """{"error": "$error", "error_description": "$description"}""" enqueueMockResponse(json, statusCode) } @@ -87,6 +97,51 @@ public class MfaApiClientTest { } + @Test + public fun shouldIncludeAuth0ClientHeaderInGetAuthenticators(): Unit = runTest { + val json = """[{"id": "sms|dev_123", "type": "oob", "active": true}]""" + enqueueMockResponse(json) + + mfaClient.getAuthenticators(listOf("oob")).await() + + val request = mockServer.takeRequest() + assertThat(request.getHeader("Auth0-Client"), `is`(notNullValue())) + } + + @Test + public fun shouldIncludeAuth0ClientHeaderInEnroll(): Unit = runTest { + val json = """{"id": "sms|dev_123", "auth_session": "session_abc"}""" + enqueueMockResponse(json) + + mfaClient.enroll(MfaEnrollmentType.Phone("+12025550135")).await() + + val request = mockServer.takeRequest() + assertThat(request.getHeader("Auth0-Client"), `is`(notNullValue())) + } + + @Test + public fun shouldIncludeAuth0ClientHeaderInChallenge(): Unit = runTest { + val json = """{"challenge_type": "oob", "oob_code": "oob_123"}""" + enqueueMockResponse(json) + + mfaClient.challenge("sms|dev_123").await() + + val request = mockServer.takeRequest() + assertThat(request.getHeader("Auth0-Client"), `is`(notNullValue())) + } + + @Test + public fun shouldIncludeAuth0ClientHeaderInVerify(): Unit = runTest { + val json = + """{"access_token": "$ACCESS_TOKEN", "id_token": "$ID_TOKEN", "token_type": "Bearer", "expires_in": 86400}""" + enqueueMockResponse(json) + + mfaClient.verify(MfaVerificationType.Otp("123456")).await() + + val request = mockServer.takeRequest() + assertThat(request.getHeader("Auth0-Client"), `is`(notNullValue())) + } + @Test public fun shouldGetAuthenticatorsSuccess(): Unit = runTest { val json = """[ @@ -436,7 +491,8 @@ public class MfaApiClientTest { @Test public fun shouldVerifyOtpWithCorrectGrantType(): Unit = runTest { - val json = """{"access_token": "$ACCESS_TOKEN", "id_token": "$ID_TOKEN", "token_type": "Bearer", "expires_in": 86400}""" + val json = + """{"access_token": "$ACCESS_TOKEN", "id_token": "$ID_TOKEN", "token_type": "Bearer", "expires_in": 86400}""" enqueueMockResponse(json) mfaClient.verify(MfaVerificationType.Otp("123456")).await() @@ -500,10 +556,12 @@ public class MfaApiClientTest { @Test public fun shouldVerifyOobWithoutBindingCodeSuccess(): Unit = runTest { - val json = """{"access_token": "$ACCESS_TOKEN", "id_token": "$ID_TOKEN", "token_type": "Bearer", "expires_in": 86400}""" + val json = + """{"access_token": "$ACCESS_TOKEN", "id_token": "$ID_TOKEN", "token_type": "Bearer", "expires_in": 86400}""" enqueueMockResponse(json) - val credentials = mfaClient.verify(MfaVerificationType.Oob(oobCode = "oob_code_123")).await() + val credentials = + mfaClient.verify(MfaVerificationType.Oob(oobCode = "oob_code_123")).await() assertThat(credentials, `is`(notNullValue())) assertThat(credentials.accessToken, `is`(ACCESS_TOKEN)) @@ -511,10 +569,12 @@ public class MfaApiClientTest { @Test public fun shouldVerifyOobWithCorrectParameters(): Unit = runTest { - val json = """{"access_token": "$ACCESS_TOKEN", "id_token": "$ID_TOKEN", "token_type": "Bearer", "expires_in": 86400}""" + val json = + """{"access_token": "$ACCESS_TOKEN", "id_token": "$ID_TOKEN", "token_type": "Bearer", "expires_in": 86400}""" enqueueMockResponse(json) - mfaClient.verify(MfaVerificationType.Oob(oobCode = "oob_code_123", bindingCode = "654321")).await() + mfaClient.verify(MfaVerificationType.Oob(oobCode = "oob_code_123", bindingCode = "654321")) + .await() val request = mockServer.takeRequest() assertThat(request.path, `is`("/oauth/token")) @@ -530,7 +590,8 @@ public class MfaApiClientTest { @Test public fun shouldVerifyOobWithoutBindingCodeInRequest(): Unit = runTest { - val json = """{"access_token": "$ACCESS_TOKEN", "id_token": "$ID_TOKEN", "token_type": "Bearer", "expires_in": 86400}""" + val json = + """{"access_token": "$ACCESS_TOKEN", "id_token": "$ID_TOKEN", "token_type": "Bearer", "expires_in": 86400}""" enqueueMockResponse(json) mfaClient.verify(MfaVerificationType.Oob(oobCode = "oob_code_123")).await() @@ -565,7 +626,8 @@ public class MfaApiClientTest { }""" enqueueMockResponse(json) - val credentials = mfaClient.verify(MfaVerificationType.RecoveryCode("OLD_RECOVERY_CODE")).await() + val credentials = + mfaClient.verify(MfaVerificationType.RecoveryCode("OLD_RECOVERY_CODE")).await() assertThat(credentials, `is`(notNullValue())) assertThat(credentials.accessToken, `is`(ACCESS_TOKEN)) @@ -574,7 +636,8 @@ public class MfaApiClientTest { @Test public fun shouldVerifyRecoveryCodeWithCorrectParameters(): Unit = runTest { - val json = """{"access_token": "$ACCESS_TOKEN", "id_token": "$ID_TOKEN", "token_type": "Bearer", "expires_in": 86400}""" + val json = + """{"access_token": "$ACCESS_TOKEN", "id_token": "$ID_TOKEN", "token_type": "Bearer", "expires_in": 86400}""" enqueueMockResponse(json) mfaClient.verify(MfaVerificationType.RecoveryCode("RECOVERY_123")).await() @@ -671,7 +734,8 @@ public class MfaApiClientTest { @Test public fun shouldVerifyOtpWithCallback(): Unit { - val json = """{"access_token": "$ACCESS_TOKEN", "id_token": "$ID_TOKEN", "token_type": "Bearer", "expires_in": 86400}""" + val json = + """{"access_token": "$ACCESS_TOKEN", "id_token": "$ID_TOKEN", "token_type": "Bearer", "expires_in": 86400}""" enqueueMockResponse(json) val callback = MockCallback() @@ -763,8 +827,10 @@ public class MfaApiClientTest { private companion object { private const val CLIENT_ID = "CLIENT_ID" private const val MFA_TOKEN = "MFA_TOKEN_123" - private const val ACCESS_TOKEN = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U" - private const val ID_TOKEN = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIn0.Gfx6VO9tcxwk6xqx9yYzSfebfeakZp5JYIgP_edcw_A" + private const val ACCESS_TOKEN = + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U" + private const val ID_TOKEN = + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIn0.Gfx6VO9tcxwk6xqx9yYzSfebfeakZp5JYIgP_edcw_A" private const val REFRESH_TOKEN = "REFRESH_TOKEN" } } diff --git a/auth0/src/test/java/com/auth0/android/authentication/storage/CryptoUtilTest.java b/auth0/src/test/java/com/auth0/android/authentication/storage/CryptoUtilTest.java index 1fc567d8..1f28b30a 100644 --- a/auth0/src/test/java/com/auth0/android/authentication/storage/CryptoUtilTest.java +++ b/auth0/src/test/java/com/auth0/android/authentication/storage/CryptoUtilTest.java @@ -31,6 +31,7 @@ import org.junit.Test; import org.junit.runner.RunWith; import org.mockito.ArgumentCaptor; +import org.mockito.InOrder; import org.mockito.MockedConstruction; import org.mockito.MockedStatic; import org.mockito.Mockito; @@ -1792,6 +1793,299 @@ public void shouldGenerateNewKeyWhenMigrationFails() throws Exception { Mockito.verify(storage, times(1)).remove(OLD_KEY_ALIAS); } + @Test + public void shouldWrapProviderExceptionFromCipherInitInRSADecryptAsCryptoException() { + Assert.assertThrows("The RSA key's padding mode is incompatible with the current cipher.", + CryptoException.class, () -> { + PrivateKey privateKey = mock(PrivateKey.class); + KeyStore.PrivateKeyEntry privateKeyEntry = mock(KeyStore.PrivateKeyEntry.class); + doReturn(privateKey).when(privateKeyEntry).getPrivateKey(); + doReturn(privateKeyEntry).when(cryptoUtil).getRSAKeyEntry(); + doThrow(new ProviderException(new KeyStoreException("Incompatible padding mode"))) + .when(rsaOaepCipher).init(eq(Cipher.DECRYPT_MODE), eq(privateKey), + any(AlgorithmParameterSpec.class)); + + cryptoUtil.RSADecrypt(new byte[]{1, 2, 3}); + }); + } + + @Test + public void shouldWrapProviderExceptionFromCipherInitInRSAEncryptAsCryptoException() { + Assert.assertThrows("The RSA key's padding mode is incompatible with the current cipher.", + CryptoException.class, () -> { + PublicKey publicKey = mock(PublicKey.class); + Certificate certificate = mock(Certificate.class); + doReturn(publicKey).when(certificate).getPublicKey(); + KeyStore.PrivateKeyEntry privateKeyEntry = mock(KeyStore.PrivateKeyEntry.class); + doReturn(certificate).when(privateKeyEntry).getCertificate(); + doReturn(privateKeyEntry).when(cryptoUtil).getRSAKeyEntry(); + doThrow(new ProviderException(new KeyStoreException("Incompatible padding mode"))) + .when(rsaOaepCipher).init(eq(Cipher.ENCRYPT_MODE), eq(publicKey), + any(AlgorithmParameterSpec.class)); + + cryptoUtil.RSAEncrypt(new byte[]{1, 2, 3}); + }); + } + + @Test + public void shouldTriggerPKCS1MigrationWhenRSADecryptThrowsProviderException() throws Exception { + CryptoUtil cryptoUtil = newCryptoUtilSpy(); + + byte[] encryptedAESPKCS1 = new byte[]{10, 11, 12, 13}; + byte[] aesKeyBytes = new byte[32]; + Arrays.fill(aesKeyBytes, (byte) 0xAB); + byte[] reEncryptedOAEP = new byte[]{20, 21, 22, 23}; + String encodedPKCS1 = "pkcs1_encoded"; + String encodedOAEP = "oaep_encoded"; + + when(storage.retrieveString(KEY_ALIAS)).thenReturn(encodedPKCS1); + base64Mock.when(() -> Base64.decode(encodedPKCS1, Base64.DEFAULT)).thenReturn(encryptedAESPKCS1); + base64Mock.when(() -> Base64.encode(reEncryptedOAEP, Base64.DEFAULT)) + .thenReturn(encodedOAEP.getBytes(StandardCharsets.UTF_8)); + + doThrow(new CryptoException( + "The RSA key's padding mode is incompatible with the current cipher.", + new ProviderException(new KeyStoreException("Incompatible padding mode")))) + .when(cryptoUtil).RSADecrypt(encryptedAESPKCS1); + + when(keyStore.containsAlias(KEY_ALIAS)).thenReturn(true); + KeyStore.PrivateKeyEntry mockEntry = mock(KeyStore.PrivateKeyEntry.class); + when(mockEntry.getPrivateKey()).thenReturn(mock(PrivateKey.class)); + when(keyStore.getEntry(eq(KEY_ALIAS), nullable(KeyStore.ProtectionParameter.class))) + .thenReturn(mockEntry); + when(rsaPkcs1Cipher.doFinal(encryptedAESPKCS1)).thenReturn(aesKeyBytes); + doReturn(reEncryptedOAEP).when(cryptoUtil).RSAEncrypt(aesKeyBytes); + + byte[] result = cryptoUtil.getAESKey(); + + assertThat(result, is(aesKeyBytes)); + Mockito.verify(storage).store(KEY_ALIAS, encodedOAEP); + Mockito.verify(keyStore).deleteEntry(KEY_ALIAS); + } + + @Test + public void shouldDeleteOldRSAKeyBeforeReEncryptingInTryMigrateLegacyAESKey() throws Exception { + CryptoUtil cryptoUtil = newCryptoUtilSpy(); + + byte[] aesKeyBytes = new byte[32]; + Arrays.fill(aesKeyBytes, (byte) 0xCD); + byte[] encryptedOldAES = new byte[]{1, 2, 3, 4}; + byte[] encryptedNewAES = new byte[]{4, 5, 6}; + String encodedOldAES = "old_pkcs1_encoded"; + String encodedNewAES = "new_oaep_encoded"; + + when(storage.retrieveString(KEY_ALIAS)).thenReturn(null); + when(storage.retrieveString(OLD_KEY_ALIAS)).thenReturn(encodedOldAES); + + base64Mock.when(() -> Base64.decode(encodedOldAES, Base64.DEFAULT)).thenReturn(encryptedOldAES); + base64Mock.when(() -> Base64.encode(encryptedNewAES, Base64.DEFAULT)) + .thenReturn(encodedNewAES.getBytes(StandardCharsets.UTF_8)); + + KeyStore.PrivateKeyEntry mockEntry = mock(KeyStore.PrivateKeyEntry.class); + PrivateKey mockPrivateKey = mock(PrivateKey.class); + when(mockEntry.getPrivateKey()).thenReturn(mockPrivateKey); + doReturn(mockEntry).when(cryptoUtil).getRSAKeyEntry(); + + when(rsaPkcs1Cipher.doFinal(encryptedOldAES)).thenReturn(aesKeyBytes); + + doReturn(encryptedNewAES).when(cryptoUtil).RSAEncrypt(aesKeyBytes); + + byte[] result = cryptoUtil.getAESKey(); + + assertThat(result, is(aesKeyBytes)); + Mockito.verify(storage).store(KEY_ALIAS, encodedNewAES); + Mockito.verify(storage).remove(OLD_KEY_ALIAS); + + InOrder inOrder = Mockito.inOrder(keyStore, cryptoUtil); + inOrder.verify(keyStore).deleteEntry(KEY_ALIAS); + inOrder.verify(keyStore).deleteEntry(OLD_KEY_ALIAS); + inOrder.verify(cryptoUtil).RSAEncrypt(aesKeyBytes); + } + + @Test + public void shouldDeleteStaleRSAKeyAndRethrowOnIncompatibleDeviceExceptionDuringGenerateNewAESKey() throws Exception { + CryptoUtil cryptoUtil = newCryptoUtilSpy(); + + when(storage.retrieveString(KEY_ALIAS)).thenReturn(null); + when(storage.retrieveString(OLD_KEY_ALIAS)).thenReturn(null); + + byte[] newAESKey = new byte[32]; + Arrays.fill(newAESKey, (byte) 0xEF); + SecretKey mockSecret = mock(SecretKey.class); + when(mockSecret.getEncoded()).thenReturn(newAESKey); + when(keyGenerator.generateKey()).thenReturn(mockSecret); + + doThrow(new IncompatibleDeviceException( + new ProviderException(new KeyStoreException("Incompatible padding mode")))) + .when(cryptoUtil).RSAEncrypt(newAESKey); + + Assert.assertThrows(IncompatibleDeviceException.class, () -> cryptoUtil.getAESKey()); + + Mockito.verify(keyStore).deleteEntry(KEY_ALIAS); + Mockito.verify(keyStore).deleteEntry(OLD_KEY_ALIAS); + Mockito.verify(storage).remove(KEY_ALIAS); + Mockito.verify(storage).remove(OLD_KEY_ALIAS); + } + + @Test + public void shouldHandleProviderExceptionInAttemptPKCS1Migration() throws Exception { + CryptoUtil cryptoUtil = newCryptoUtilSpy(); + + byte[] encryptedAESPKCS1 = new byte[]{10, 11, 12, 13}; + String encodedPKCS1 = "pkcs1_encoded"; + + when(storage.retrieveString(KEY_ALIAS)).thenReturn(encodedPKCS1); + base64Mock.when(() -> Base64.decode(encodedPKCS1, Base64.DEFAULT)).thenReturn(encryptedAESPKCS1); + + doThrow(new CryptoException( + "The RSA key's padding mode is incompatible with the current cipher.", + new ProviderException(new KeyStoreException("Incompatible padding mode")))) + .when(cryptoUtil).RSADecrypt(encryptedAESPKCS1); + + when(keyStore.containsAlias(KEY_ALIAS)).thenReturn(true); + KeyStore.PrivateKeyEntry mockEntry = mock(KeyStore.PrivateKeyEntry.class); + when(mockEntry.getPrivateKey()).thenReturn(mock(PrivateKey.class)); + when(keyStore.getEntry(eq(KEY_ALIAS), nullable(KeyStore.ProtectionParameter.class))) + .thenReturn(mockEntry); + when(rsaPkcs1Cipher.doFinal(encryptedAESPKCS1)) + .thenThrow(new ProviderException(new KeyStoreException("Incompatible padding mode"))); + + when(storage.retrieveString(OLD_KEY_ALIAS)).thenReturn(null); + + byte[] newAESKey = new byte[32]; + Arrays.fill(newAESKey, (byte) 0xDD); + SecretKey mockSecret = mock(SecretKey.class); + when(mockSecret.getEncoded()).thenReturn(newAESKey); + when(keyGenerator.generateKey()).thenReturn(mockSecret); + + byte[] encryptedNewKey = new byte[]{30, 31, 32, 33}; + doReturn(encryptedNewKey).when(cryptoUtil).RSAEncrypt(any(byte[].class)); + String encodedNewKey = "new_key_encoded"; + base64Mock.when(() -> Base64.encode(encryptedNewKey, Base64.DEFAULT)) + .thenReturn(encodedNewKey.getBytes(StandardCharsets.UTF_8)); + + byte[] result = cryptoUtil.getAESKey(); + + assertThat(result, is(newAESKey)); + Mockito.verify(storage).store(KEY_ALIAS, encodedNewKey); + } + + @Test + public void shouldHandleProviderExceptionInTryMigrateLegacyAESKey() throws Exception { + CryptoUtil cryptoUtil = newCryptoUtilSpy(); + + when(storage.retrieveString(KEY_ALIAS)).thenReturn(null); + + String encodedOldAES = "old_legacy_key"; + byte[] encryptedOldAES = new byte[]{1, 2, 3, 4}; + when(storage.retrieveString(OLD_KEY_ALIAS)).thenReturn(encodedOldAES); + + base64Mock.when(() -> Base64.decode(encodedOldAES, Base64.DEFAULT)).thenReturn(encryptedOldAES); + + KeyStore.PrivateKeyEntry mockEntry = mock(KeyStore.PrivateKeyEntry.class); + PrivateKey mockPrivateKey = mock(PrivateKey.class); + when(mockEntry.getPrivateKey()).thenReturn(mockPrivateKey); + doReturn(mockEntry).when(cryptoUtil).getRSAKeyEntry(); + + when(rsaPkcs1Cipher.doFinal(encryptedOldAES)) + .thenThrow(new ProviderException(new KeyStoreException("Incompatible padding mode"))); + + byte[] newAESKey = new byte[32]; + Arrays.fill(newAESKey, (byte) 0xEE); + SecretKey mockSecret = mock(SecretKey.class); + when(mockSecret.getEncoded()).thenReturn(newAESKey); + when(keyGenerator.generateKey()).thenReturn(mockSecret); + + byte[] encryptedNewKey = new byte[]{40, 41, 42, 43}; + doReturn(encryptedNewKey).when(cryptoUtil).RSAEncrypt(any(byte[].class)); + String encodedNewKey = "new_generated_key"; + base64Mock.when(() -> Base64.encode(encryptedNewKey, Base64.DEFAULT)) + .thenReturn(encodedNewKey.getBytes(StandardCharsets.UTF_8)); + + byte[] result = cryptoUtil.getAESKey(); + + assertThat(result, is(newAESKey)); + Mockito.verify(storage).store(KEY_ALIAS, encodedNewKey); + } + + @Test + public void shouldFallThroughToKeyRegenerationWhenMigrationFailsWithCryptoException() throws Exception { + CryptoUtil cryptoUtil = newCryptoUtilSpy(); + + byte[] encryptedAESPKCS1 = new byte[]{10, 11, 12, 13}; + String encodedPKCS1 = "pkcs1_encoded"; + + when(storage.retrieveString(KEY_ALIAS)).thenReturn(encodedPKCS1); + base64Mock.when(() -> Base64.decode(encodedPKCS1, Base64.DEFAULT)).thenReturn(encryptedAESPKCS1); + + doThrow(new CryptoException( + "The RSA key's padding mode is incompatible with the current cipher.", + new ProviderException(new KeyStoreException("Incompatible padding mode")))) + .when(cryptoUtil).RSADecrypt(encryptedAESPKCS1); + + when(keyStore.containsAlias(KEY_ALIAS)).thenReturn(false); + when(keyStore.containsAlias(OLD_KEY_ALIAS)).thenReturn(false); + + when(storage.retrieveString(OLD_KEY_ALIAS)).thenReturn(null); + + byte[] newAESKey = new byte[32]; + Arrays.fill(newAESKey, (byte) 0xFF); + SecretKey mockSecret = mock(SecretKey.class); + when(mockSecret.getEncoded()).thenReturn(newAESKey); + when(keyGenerator.generateKey()).thenReturn(mockSecret); + + byte[] encryptedNewKey = new byte[]{50, 51, 52, 53}; + doReturn(encryptedNewKey).when(cryptoUtil).RSAEncrypt(any(byte[].class)); + String encodedNewKey = "regenerated_key"; + base64Mock.when(() -> Base64.encode(encryptedNewKey, Base64.DEFAULT)) + .thenReturn(encodedNewKey.getBytes(StandardCharsets.UTF_8)); + + byte[] result = cryptoUtil.getAESKey(); + + assertThat(result, is(newAESKey)); + Mockito.verify(storage).store(KEY_ALIAS, encodedNewKey); + Mockito.verify(keyStore).deleteEntry(KEY_ALIAS); + Mockito.verify(keyStore).deleteEntry(OLD_KEY_ALIAS); + } + + @Test + public void shouldNotPropagateProviderExceptionAsIncompatibleDeviceException() throws Exception { + CryptoUtil cryptoUtil = newCryptoUtilSpy(); + + byte[] encryptedAESPKCS1 = new byte[]{10, 11, 12, 13}; + String encodedPKCS1 = "pkcs1_encoded"; + + when(storage.retrieveString(KEY_ALIAS)).thenReturn(encodedPKCS1); + base64Mock.when(() -> Base64.decode(encodedPKCS1, Base64.DEFAULT)).thenReturn(encryptedAESPKCS1); + + doThrow(new CryptoException( + "The RSA key's padding mode is incompatible with the current cipher.", + new ProviderException(new KeyStoreException("Incompatible padding mode")))) + .when(cryptoUtil).RSADecrypt(encryptedAESPKCS1); + + when(keyStore.containsAlias(KEY_ALIAS)).thenReturn(false); + when(keyStore.containsAlias(OLD_KEY_ALIAS)).thenReturn(false); + + when(storage.retrieveString(OLD_KEY_ALIAS)).thenReturn(null); + + byte[] newAESKey = new byte[32]; + Arrays.fill(newAESKey, (byte) 0xAA); + SecretKey mockSecret = mock(SecretKey.class); + when(mockSecret.getEncoded()).thenReturn(newAESKey); + when(keyGenerator.generateKey()).thenReturn(mockSecret); + + byte[] encryptedNewKey = new byte[]{60, 61, 62, 63}; + doReturn(encryptedNewKey).when(cryptoUtil).RSAEncrypt(any(byte[].class)); + String encodedNewKey = "recovered_key"; + base64Mock.when(() -> Base64.encode(encryptedNewKey, Base64.DEFAULT)) + .thenReturn(encodedNewKey.getBytes(StandardCharsets.UTF_8)); + + byte[] result = cryptoUtil.getAESKey(); + + assertThat(result, is(notNullValue())); + assertThat(result, is(newAESKey)); + } + /* * Helper methods */ diff --git a/auth0/src/test/java/com/auth0/android/myaccount/MyAccountAPIClientTest.kt b/auth0/src/test/java/com/auth0/android/myaccount/MyAccountAPIClientTest.kt index 388530df..3dca6f93 100644 --- a/auth0/src/test/java/com/auth0/android/myaccount/MyAccountAPIClientTest.kt +++ b/auth0/src/test/java/com/auth0/android/myaccount/MyAccountAPIClientTest.kt @@ -50,6 +50,14 @@ public class MyAccountAPIClientTest { mockAPI.shutdown() } + @Test + public fun `should sent user-agent header with request`() { + val callback = MockMyAccountCallback>() + client.getFactors().start(callback) + val request = mockAPI.takeRequest() + assertThat(request.getHeader("Auth0-Client"), Matchers.`is`(Matchers.notNullValue())) + } + @Test public fun `passkeyEnrollmentChallenge should build correct URL`() { val callback = MockMyAccountCallback() @@ -113,7 +121,10 @@ public class MyAccountAPIClientTest { } mockAPI.takeRequest() assertThat(error, Matchers.notNullValue()) - assertThat(error?.message, Matchers.`is`("Authentication method ID not found in Location header.")) + assertThat( + error?.message, + Matchers.`is`("Authentication method ID not found in Location header.") + ) } @@ -362,7 +373,10 @@ public class MyAccountAPIClientTest { public fun `updateAuthenticationMethodById for phone should build correct URL and payload`() { val callback = MockMyAccountCallback() val methodId = "phone|12345" - client.updateAuthenticationMethodById(methodId, preferredAuthenticationMethod = PhoneAuthenticationMethodType.SMS).start(callback) + client.updateAuthenticationMethodById( + methodId, + preferredAuthenticationMethod = PhoneAuthenticationMethodType.SMS + ).start(callback) val request = mockAPI.takeRequest() val body = bodyFromRequest(request) @@ -376,7 +390,8 @@ public class MyAccountAPIClientTest { val callback = MockMyAccountCallback() val methodId = "totp|12345" val name = "My Authenticator" - client.updateAuthenticationMethodById(methodId, authenticationMethodName = name).start(callback) + client.updateAuthenticationMethodById(methodId, authenticationMethodName = name) + .start(callback) val request = mockAPI.takeRequest() val body = bodyFromRequest(request) @@ -449,7 +464,10 @@ public class MyAccountAPIClientTest { val request = mockAPI.takeRequest() val body = bodyFromRequest(request) - assertThat(request.path, Matchers.equalTo("/me/v1/authentication-methods/email%7C123/verify")) + assertThat( + request.path, + Matchers.equalTo("/me/v1/authentication-methods/email%7C123/verify") + ) assertThat(request.method, Matchers.equalTo("POST")) assertThat(body, Matchers.hasEntry("otp_code", otp as Any)) assertThat(body, Matchers.hasEntry("auth_session", session as Any)) @@ -497,6 +515,7 @@ public class MyAccountAPIClientTest { private companion object { private const val CLIENT_ID = "CLIENTID" + private const val DOMAIN = "test-domain" private const val USER_IDENTITY = "user123" private const val CONNECTION = "passkey-connection" private const val ACCESS_TOKEN = "accessToken"