From ec4c6348fbb4008529e4b2d7612ae67a68362c24 Mon Sep 17 00:00:00 2001 From: Prince Mathew <17837162+pmathew92@users.noreply.github.com> Date: Thu, 26 Feb 2026 14:43:26 +0530 Subject: [PATCH 01/14] doc: Removed EA tag from N2W APIs (#920) --- EXAMPLES.md | 8 ++----- .../authentication/AuthenticationAPIClient.kt | 6 ----- .../storage/CredentialsManager.kt | 20 ---------------- .../storage/SecureCredentialsManager.kt | 23 ------------------- 4 files changed, 2 insertions(+), 55 deletions(-) diff --git a/EXAMPLES.md b/EXAMPLES.md index cae42b8c..1ad204be 100644 --- a/EXAMPLES.md +++ b/EXAMPLES.md @@ -29,7 +29,7 @@ - [Sign Up with a database connection](#sign-up-with-a-database-connection) - [Get user information](#get-user-information) - [Custom Token Exchange](#custom-token-exchange) - - [Native to Web SSO login [EA]](#native-to-web-sso-login-ea) + - [Native to Web SSO login](#native-to-web-sso-login) - [DPoP [EA]](#dpop-ea-1) - [My Account API](#my-account-api) - [Enroll a new passkey](#enroll-a-new-passkey) @@ -1552,11 +1552,7 @@ authentication -## Native to Web SSO login [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. +## Native to Web SSO login This feature allows you to authenticate a user in a web session using the refresh token obtained from the native session without requiring the user to log in again. 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 eec6bd1f..31237730 100755 --- a/auth0/src/main/java/com/auth0/android/authentication/AuthenticationAPIClient.kt +++ b/auth0/src/main/java/com/auth0/android/authentication/AuthenticationAPIClient.kt @@ -1013,12 +1013,6 @@ public class AuthenticationAPIClient @VisibleForTesting(otherwise = VisibleForTe * parameter with the session transfer token. For example, * `https://example.com/login?session_transfer_token=THE_TOKEN`. * - * ##Availability - * - * 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. - * * * @param refreshToken A valid refresh token obtained as part of Auth0 authentication * @return a request to fetch a session transfer token 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 31f8e62f..0cc5c61f 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 @@ -102,11 +102,6 @@ public class CredentialsManager @VisibleForTesting(otherwise = VisibleForTesting * parameter with the session transfer token. For example, * `https://example.com/login?session_transfer_token=THE_TOKEN`. * - * ## Availability - * - * 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. * * It will fail with [CredentialsManagerException] if the existing refresh_token is null or no longer valid. * This method will handle saving the refresh_token, if a new one is issued. @@ -123,11 +118,6 @@ public class CredentialsManager @VisibleForTesting(otherwise = VisibleForTesting * parameter with the session transfer token. For example, * `https://example.com/login?session_transfer_token=THE_TOKEN`. * - * ## Availability - * - * 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. * * It will fail with [CredentialsManagerException] if the existing refresh_token is null or no longer valid. * This method will handle saving the refresh_token, if a new one is issued. @@ -185,11 +175,6 @@ public class CredentialsManager @VisibleForTesting(otherwise = VisibleForTesting * parameter with the session transfer token. For example, * `https://example.com/login?session_transfer_token=THE_TOKEN`. * - * ## Availability - * - * 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. * * It will fail with [CredentialsManagerException] if the existing refresh_token is null or no longer valid. * This method will handle saving the refresh_token, if a new one is issued. @@ -208,11 +193,6 @@ public class CredentialsManager @VisibleForTesting(otherwise = VisibleForTesting * parameter with the session transfer token. For example, * `https://example.com/login?session_transfer_token=THE_TOKEN`. * - * ## Availability - * - * 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. * * It will fail with [CredentialsManagerException] if the existing refresh_token is null or no longer valid. * This method will handle saving the refresh_token, if a new one is issued. 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 a6e86c49..4a367e77 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 @@ -244,12 +244,6 @@ public class SecureCredentialsManager @VisibleForTesting(otherwise = VisibleForT * parameter with the session transfer token. For example, * `https://example.com/login?session_transfer_token=THE_TOKEN`. * - * ## Availability - * - * 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. - * * It will fail with [CredentialsManagerException] if the existing refresh_token is null or no longer valid. * This method will handle saving the refresh_token, if a new one is issued. */ @@ -265,11 +259,6 @@ public class SecureCredentialsManager @VisibleForTesting(otherwise = VisibleForT * parameter with the session transfer token. For example, * `https://example.com/login?session_transfer_token=THE_TOKEN`. * - * ## Availability - * - * 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. * * It will fail with [CredentialsManagerException] if the existing refresh_token is null or no longer valid. * This method will handle saving the refresh_token, if a new one is issued. @@ -346,12 +335,6 @@ public class SecureCredentialsManager @VisibleForTesting(otherwise = VisibleForT * parameter with the session transfer token. For example, * `https://example.com/login?session_transfer_token=THE_TOKEN`. * - * ## Availability - * - * 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. - * * It will fail with [CredentialsManagerException] if the existing refresh_token is null or no longer valid. * This method will handle saving the refresh_token, if a new one is issued. */ @@ -369,12 +352,6 @@ public class SecureCredentialsManager @VisibleForTesting(otherwise = VisibleForT * parameter with the session transfer token. For example, * `https://example.com/login?session_transfer_token=THE_TOKEN`. * - * ## Availability - * - * 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. - * * It will fail with [CredentialsManagerException] if the existing refresh_token is null or no longer valid. * This method will handle saving the refresh_token, if a new one is issued. */ From f0ca8f6f963f138ee61c25fdd95fb19ea82bafeb Mon Sep 17 00:00:00 2001 From: Prince Mathew <17837162+pmathew92@users.noreply.github.com> Date: Thu, 26 Feb 2026 15:04:49 +0530 Subject: [PATCH 02/14] chore: Removed claude PR-analyzer workflow (#921) --- .github/workflows/claude-code-review.yml | 18 ------------------ 1 file changed, 18 deletions(-) delete mode 100644 .github/workflows/claude-code-review.yml diff --git a/.github/workflows/claude-code-review.yml b/.github/workflows/claude-code-review.yml deleted file mode 100644 index d2183bca..00000000 --- a/.github/workflows/claude-code-review.yml +++ /dev/null @@ -1,18 +0,0 @@ -name: Claude Code PR Review - -on: - issue_comment: - types: [ created ] - pull_request_review_comment: - types: [ created ] - pull_request_review: - types: [ submitted ] - -jobs: - claude-review: - permissions: - contents: write - issues: write - pull-requests: write - id-token: write - uses: auth0/auth0-ai-pr-analyzer-gh-action/.github/workflows/claude-code-review.yml@main \ No newline at end of file From 43f9358c07efd0d8df6b7b1e473bf4320b8c70c0 Mon Sep 17 00:00:00 2001 From: utkrishtS Date: Fri, 27 Feb 2026 04:21:04 +0530 Subject: [PATCH 03/14] =?UTF-8?q?SDK-7858=20fix:=20Handle=20ProviderExcept?= =?UTF-8?q?ion=20in=20PKCS1=E2=86=92OAEP=20key=20migration=20to=20prevent?= =?UTF-8?q?=20saveCredentials()=20crash?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../authentication/storage/CryptoUtil.java | 25 +++- .../storage/CryptoUtilTest.java | 141 ++++++++++++++++++ 2 files changed, 162 insertions(+), 4 deletions(-) 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..ef31c448 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,7 +343,8 @@ 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 | ProviderException e) { /* * This exceptions are safe to be ignored: * @@ -356,6 +357,11 @@ byte[] RSADecrypt(byte[] encryptedInput) throws IncompatibleDeviceException, Cry * Thrown if the given key is inappropriate for initializing this cipher. * - InvalidAlgorithmParameterException: * Thrown if the OAEP parameters are invalid or unsupported. + * - ProviderException: + * Thrown on Android 12+ (Keystore2) when the key's padding restriction is + * incompatible with the cipher transformation (e.g. a PKCS1-restricted key + * initialised with an OAEP spec). On Android < 12 this surfaces as + * InvalidKeyException instead. * * Read more in https://developer.android.com/reference/javax/crypto/Cipher */ @@ -394,7 +400,8 @@ 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 | ProviderException e) { /* * This exceptions are safe to be ignored: * @@ -407,6 +414,11 @@ byte[] RSAEncrypt(byte[] decryptedInput) throws IncompatibleDeviceException, Cry * Thrown if the given key is inappropriate for initializing this cipher. * - InvalidAlgorithmParameterException: * Thrown if the OAEP parameters are invalid or unsupported. + * - ProviderException: + * Thrown on Android 12+ (Keystore2) when the key's padding restriction is + * incompatible with the cipher transformation (e.g. a PKCS1-restricted key + * initialised with an OAEP spec). On Android < 12 this surfaces as + * InvalidKeyException instead. * * Read more in https://developer.android.com/reference/javax/crypto/Cipher */ @@ -593,7 +605,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); @@ -632,8 +646,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/test/java/com/auth0/android/authentication/storage/CryptoUtilTest.java b/auth0/src/test/java/com/auth0/android/authentication/storage/CryptoUtilTest.java index d9f84c91..e26104b0 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 @@ -16,6 +16,7 @@ import org.junit.Test; import org.junit.runner.RunWith; import org.mockito.ArgumentCaptor; +import org.mockito.InOrder; import org.mockito.Mockito; import org.mockito.stubbing.Answer; import org.powermock.api.mockito.PowerMockito; @@ -1941,4 +1942,144 @@ public void shouldGenerateNewKeyWhenMigrationFails() throws Exception { Mockito.verify(storage, times(1)).remove(KEY_ALIAS); Mockito.verify(storage, times(1)).remove(OLD_KEY_ALIAS); } + + @Test + public void shouldWrapProviderExceptionFromCipherInitInRSADecryptAsIncompatibleDevice() { + Assert.assertThrows("The device is not compatible with the CryptoUtil class", + IncompatibleDeviceException.class, () -> { + PrivateKey privateKey = PowerMockito.mock(PrivateKey.class); + KeyStore.PrivateKeyEntry privateKeyEntry = PowerMockito.mock(KeyStore.PrivateKeyEntry.class); + doReturn(privateKey).when(privateKeyEntry).getPrivateKey(); + doReturn(privateKeyEntry).when(cryptoUtil).getRSAKeyEntry(); + PowerMockito.mockStatic(Cipher.class); + PowerMockito.when(Cipher.getInstance(RSA_TRANSFORMATION)).thenReturn(rsaOaepCipher); + 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 shouldWrapProviderExceptionFromCipherInitInRSAEncryptAsIncompatibleDevice() { + Assert.assertThrows("The device is not compatible with the CryptoUtil class", + IncompatibleDeviceException.class, () -> { + PublicKey publicKey = PowerMockito.mock(PublicKey.class); + Certificate certificate = PowerMockito.mock(Certificate.class); + doReturn(publicKey).when(certificate).getPublicKey(); + KeyStore.PrivateKeyEntry privateKeyEntry = PowerMockito.mock(KeyStore.PrivateKeyEntry.class); + doReturn(certificate).when(privateKeyEntry).getCertificate(); + doReturn(privateKeyEntry).when(cryptoUtil).getRSAKeyEntry(); + PowerMockito.mockStatic(Cipher.class); + PowerMockito.when(Cipher.getInstance(RSA_TRANSFORMATION)).thenReturn(rsaOaepCipher); + 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); + PowerMockito.mockStatic(Base64.class); + PowerMockito.when(Base64.decode(encodedPKCS1, Base64.DEFAULT)).thenReturn(encryptedAESPKCS1); + PowerMockito.when(Base64.encode(reEncryptedOAEP, Base64.DEFAULT)) + .thenReturn(encodedOAEP.getBytes(StandardCharsets.UTF_8)); + + doThrow(new IncompatibleDeviceException( + 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); + + PowerMockito.mockStatic(Base64.class); + PowerMockito.when(Base64.decode(encodedOldAES, Base64.DEFAULT)).thenReturn(encryptedOldAES); + PowerMockito.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); + } } From 692d4beb83e4cfadf373854f58d9f1e154ebb9cc Mon Sep 17 00:00:00 2001 From: Prince Mathew <17837162+pmathew92@users.noreply.github.com> Date: Mon, 2 Mar 2026 16:14:37 +0530 Subject: [PATCH 04/14] fix : Added the missing user agent to MyAccount and MFAApiClient (#926) --- .../authentication/mfa/MfaApiClient.kt | 47 ++++++--- .../android/myaccount/MyAccountAPIClient.kt | 9 +- .../authentication/MfaApiClientTest.kt | 98 ++++++++++++++++--- .../myaccount/MyAccountAPIClientTest.kt | 27 ++++- 4 files changed, 145 insertions(+), 36 deletions(-) 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/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/myaccount/MyAccountAPIClientTest.kt b/auth0/src/test/java/com/auth0/android/myaccount/MyAccountAPIClientTest.kt index 342cf3b3..2d4beb41 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" From 73d6dc1c79bb0dd2e22055940d75864ff7592ec1 Mon Sep 17 00:00:00 2001 From: sanchitmehta94 Date: Mon, 2 Mar 2026 18:06:24 +0530 Subject: [PATCH 05/14] fix: correct rl-wrapper flag from --suppress_output to --suppress-output --- .github/actions/rl-scanner/action.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/actions/rl-scanner/action.yml b/.github/actions/rl-scanner/action.yml index b5519574..1d343e61 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 From 146448c2a0aa09e117b6c7b782c8587eab4aba12 Mon Sep 17 00:00:00 2001 From: Prince Mathew <17837162+pmathew92@users.noreply.github.com> Date: Wed, 4 Mar 2026 11:14:51 +0530 Subject: [PATCH 06/14] refactor : Deprecated the UsersAPIClient (#930) --- .../java/com/auth0/android/management/ManagementException.kt | 5 +++++ .../main/java/com/auth0/android/management/UsersAPIClient.kt | 5 +++++ 2 files changed, 10 insertions(+) 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, From 15eb856ee80a295f74c60e9173a6e0151aad5976 Mon Sep 17 00:00:00 2001 From: utkrishtS Date: Fri, 6 Mar 2026 10:28:07 +0530 Subject: [PATCH 07/14] fix: Wrap ProviderException as CryptoException to enable key recovery on Android 12+ --- .../authentication/storage/CryptoUtil.java | 47 ++++++++------ .../storage/CryptoUtilTest.java | 12 ++-- .../com/auth0/sample/DatabaseLoginFragment.kt | 64 +++++++++++++++++-- .../res/layout/fragment_database_login.xml | 18 +++++- 4 files changed, 111 insertions(+), 30 deletions(-) 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 ef31c448..e6a5a000 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 @@ -344,10 +344,8 @@ byte[] RSADecrypt(byte[] encryptedInput) throws IncompatibleDeviceException, Cry cipher.init(Cipher.DECRYPT_MODE, privateKey, OAEP_SPEC); return cipher.doFinal(encryptedInput); } catch (NoSuchAlgorithmException | NoSuchPaddingException | InvalidKeyException - | InvalidAlgorithmParameterException | ProviderException e) { + | InvalidAlgorithmParameterException e) { /* - * This exceptions are safe to be ignored: - * * - NoSuchPaddingException: * Thrown if PKCS1Padding is not available. Was introduced in API 1. * - NoSuchAlgorithmException: @@ -357,16 +355,22 @@ byte[] RSADecrypt(byte[] encryptedInput) throws IncompatibleDeviceException, Cry * Thrown if the given key is inappropriate for initializing this cipher. * - InvalidAlgorithmParameterException: * Thrown if the OAEP parameters are invalid or unsupported. - * - ProviderException: - * Thrown on Android 12+ (Keystore2) when the key's padding restriction is - * incompatible with the cipher transformation (e.g. a PKCS1-restricted key - * initialised with an OAEP spec). On Android < 12 this surfaces as - * InvalidKeyException instead. * * Read more in https://developer.android.com/reference/javax/crypto/Cipher */ Log.e(TAG, "The device can't decrypt input using a RSA Key.", e); throw new IncompatibleDeviceException(e); + } catch (ProviderException e) { + /* + * On Android 12+ (Keystore2), a padding mismatch throws ProviderException + * instead of InvalidKeyException. This is a KEY incompatibility (stale PKCS1 + * key with OAEP cipher), not a DEVICE incompatibility. Wrapping as CryptoException + * allows the caller to fall through to key regeneration. + */ + 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. @@ -401,10 +405,8 @@ byte[] RSAEncrypt(byte[] decryptedInput) throws IncompatibleDeviceException, Cry cipher.init(Cipher.ENCRYPT_MODE, certificate.getPublicKey(), OAEP_SPEC); return cipher.doFinal(decryptedInput); } catch (NoSuchAlgorithmException | NoSuchPaddingException | InvalidKeyException - | InvalidAlgorithmParameterException | ProviderException e) { + | InvalidAlgorithmParameterException e) { /* - * This exceptions are safe to be ignored: - * * - NoSuchPaddingException: * Thrown if PKCS1Padding is not available. Was introduced in API 1. * - NoSuchAlgorithmException: @@ -414,16 +416,22 @@ byte[] RSAEncrypt(byte[] decryptedInput) throws IncompatibleDeviceException, Cry * Thrown if the given key is inappropriate for initializing this cipher. * - InvalidAlgorithmParameterException: * Thrown if the OAEP parameters are invalid or unsupported. - * - ProviderException: - * Thrown on Android 12+ (Keystore2) when the key's padding restriction is - * incompatible with the cipher transformation (e.g. a PKCS1-restricted key - * initialised with an OAEP spec). On Android < 12 this surfaces as - * InvalidKeyException instead. * * Read more in https://developer.android.com/reference/javax/crypto/Cipher */ Log.e(TAG, "The device can't encrypt input using a RSA Key.", e); throw new IncompatibleDeviceException(e); + } catch (ProviderException e) { + /* + * On Android 12+ (Keystore2), a padding mismatch throws ProviderException + * instead of InvalidKeyException. This is a KEY incompatibility (stale PKCS1 + * key with OAEP cipher), not a DEVICE incompatibility. Wrapping as CryptoException + * allows the caller to fall through to key regeneration. + */ + 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. @@ -479,10 +487,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); } @@ -617,7 +627,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; 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 e26104b0..55b1f507 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 @@ -1944,9 +1944,9 @@ public void shouldGenerateNewKeyWhenMigrationFails() throws Exception { } @Test - public void shouldWrapProviderExceptionFromCipherInitInRSADecryptAsIncompatibleDevice() { - Assert.assertThrows("The device is not compatible with the CryptoUtil class", - IncompatibleDeviceException.class, () -> { + public void shouldWrapProviderExceptionFromCipherInitInRSADecryptAsCryptoException() { + Assert.assertThrows("The RSA key's padding mode is incompatible with the current cipher.", + CryptoException.class, () -> { PrivateKey privateKey = PowerMockito.mock(PrivateKey.class); KeyStore.PrivateKeyEntry privateKeyEntry = PowerMockito.mock(KeyStore.PrivateKeyEntry.class); doReturn(privateKey).when(privateKeyEntry).getPrivateKey(); @@ -1962,9 +1962,9 @@ public void shouldWrapProviderExceptionFromCipherInitInRSADecryptAsIncompatibleD } @Test - public void shouldWrapProviderExceptionFromCipherInitInRSAEncryptAsIncompatibleDevice() { - Assert.assertThrows("The device is not compatible with the CryptoUtil class", - IncompatibleDeviceException.class, () -> { + public void shouldWrapProviderExceptionFromCipherInitInRSAEncryptAsCryptoException() { + Assert.assertThrows("The RSA key's padding mode is incompatible with the current cipher.", + CryptoException.class, () -> { PublicKey publicKey = PowerMockito.mock(PublicKey.class); Certificate certificate = PowerMockito.mock(Certificate.class); doReturn(publicKey).when(certificate).getPublicKey(); diff --git a/sample/src/main/java/com/auth0/sample/DatabaseLoginFragment.kt b/sample/src/main/java/com/auth0/sample/DatabaseLoginFragment.kt index 918de531..81f01c7a 100644 --- a/sample/src/main/java/com/auth0/sample/DatabaseLoginFragment.kt +++ b/sample/src/main/java/com/auth0/sample/DatabaseLoginFragment.kt @@ -2,6 +2,7 @@ package com.auth0.sample import android.os.Bundle import android.os.CancellationSignal +import android.util.Log import android.view.LayoutInflater import android.view.View import android.view.ViewGroup @@ -50,6 +51,11 @@ import java.util.concurrent.Executors */ class DatabaseLoginFragment : Fragment() { + companion object { + private const val TAG = "Auth0CrashRepro" + private const val SDK_VERSION = "3.13.0 (OAEP)" + } + private val scope = "openid profile email read:current_user update:current_user_metadata" private val account: Auth0 by lazy { @@ -122,6 +128,8 @@ class DatabaseLoginFragment : Fragment() { inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View { val binding = FragmentDatabaseLoginBinding.inflate(inflater, container, false) + binding.tvSdkVersion.text = "SDK: $SDK_VERSION" + Log.i(TAG, "=== App started with SDK version: $SDK_VERSION ===") binding.btLogin.setOnClickListener { val email = binding.textEmail.text.toString() val password = binding.textPassword.text.toString() @@ -215,6 +223,7 @@ class DatabaseLoginFragment : Fragment() { } private suspend fun dbLoginAsync(email: String, password: String) { + Log.i(TAG, ">>> dbLoginAsync() called with email=$email") try { val result = authenticationApiClient.login(email, password, "Username-Password-Authentication") @@ -222,7 +231,21 @@ class DatabaseLoginFragment : Fragment() { .addParameter("scope", scope) .addParameter("audience", audience) .await() - credentialsManager.saveCredentials(result) + Log.i(TAG, ">>> dbLoginAsync SUCCESS - got credentials for ${result.user.name}") + Log.i(TAG, ">>> Calling credentialsManager.saveCredentials()...") + try { + credentialsManager.saveCredentials(result) + Log.i(TAG, ">>> credentialsManager.saveCredentials() OK") + } catch (e: Exception) { + Log.e(TAG, ">>> credentialsManager.saveCredentials() FAILED", e) + } + Log.i(TAG, ">>> Calling secureCredentialsManager.saveCredentials()...") + try { + secureCredentialsManager.saveCredentials(result) + Log.i(TAG, ">>> secureCredentialsManager.saveCredentials() OK") + } catch (e: Exception) { + Log.e(TAG, ">>> secureCredentialsManager.saveCredentials() FAILED", e) + } Snackbar.make( requireView(), "Hello ${result.user.name}", @@ -230,24 +253,40 @@ class DatabaseLoginFragment : Fragment() { ) .show() } catch (error: AuthenticationException) { + Log.e(TAG, ">>> dbLoginAsync FAILED", error) Snackbar.make(requireView(), error.getDescription(), Snackbar.LENGTH_LONG).show() } } private fun dbLogin(email: String, password: String) { + Log.i(TAG, ">>> dbLogin() called with email=$email") authenticationApiClient.login(email, password, "Username-Password-Authentication") .validateClaims() .addParameter("scope", scope) .addParameter("audience", audience) - //Additional customization to the request goes here .start(object : Callback { override fun onFailure(error: AuthenticationException) { + Log.e(TAG, ">>> dbLogin FAILED", error) Snackbar.make(requireView(), error.getDescription(), Snackbar.LENGTH_LONG) .show() } override fun onSuccess(result: Credentials) { - credentialsManager.saveCredentials(result) + Log.i(TAG, ">>> dbLogin SUCCESS - got credentials for ${result.user.name}") + Log.i(TAG, ">>> Calling credentialsManager.saveCredentials()...") + try { + credentialsManager.saveCredentials(result) + Log.i(TAG, ">>> credentialsManager.saveCredentials() OK") + } catch (e: Exception) { + Log.e(TAG, ">>> credentialsManager.saveCredentials() FAILED", e) + } + Log.i(TAG, ">>> Calling secureCredentialsManager.saveCredentials()...") + try { + secureCredentialsManager.saveCredentials(result) + Log.i(TAG, ">>> secureCredentialsManager.saveCredentials() OK") + } catch (e: Exception) { + Log.e(TAG, ">>> secureCredentialsManager.saveCredentials() FAILED", e) + } Snackbar.make( requireView(), "Hello ${result.user.name}", @@ -258,14 +297,28 @@ class DatabaseLoginFragment : Fragment() { } private fun webAuth() { + Log.i(TAG, ">>> webAuth() called - starting browser login") WebAuthProvider.login(account) .withScheme(getString(R.string.com_auth0_scheme)) .withAudience(audience) .withScope(scope) .start(requireContext(), object : Callback { override fun onSuccess(result: Credentials) { - credentialsManager.saveCredentials(result) - secureCredentialsManager.saveCredentials(result) + Log.i(TAG, ">>> webAuth SUCCESS - got credentials for ${result.user.name}") + Log.i(TAG, ">>> Calling credentialsManager.saveCredentials()...") + try { + credentialsManager.saveCredentials(result) + Log.i(TAG, ">>> credentialsManager.saveCredentials() OK") + } catch (e: Exception) { + Log.e(TAG, ">>> credentialsManager.saveCredentials() FAILED", e) + } + Log.i(TAG, ">>> Calling secureCredentialsManager.saveCredentials()...") + try { + secureCredentialsManager.saveCredentials(result) + Log.i(TAG, ">>> secureCredentialsManager.saveCredentials() OK") + } catch (e: Exception) { + Log.e(TAG, ">>> secureCredentialsManager.saveCredentials() FAILED", e) + } Snackbar.make( requireView(), "Hello ${result.user.name}", @@ -274,6 +327,7 @@ class DatabaseLoginFragment : Fragment() { } override fun onFailure(error: AuthenticationException) { + Log.e(TAG, ">>> webAuth FAILED", error) val message = if (error.isCanceled) "Browser was closed" diff --git a/sample/src/main/res/layout/fragment_database_login.xml b/sample/src/main/res/layout/fragment_database_login.xml index 5d3731b2..96271d2d 100644 --- a/sample/src/main/res/layout/fragment_database_login.xml +++ b/sample/src/main/res/layout/fragment_database_login.xml @@ -46,6 +46,22 @@ app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toBottomOf="@+id/textEmail" /> + + + app:layout_constraintTop_toBottomOf="@+id/tvSdkVersion" /> Date: Fri, 6 Mar 2026 10:43:15 +0530 Subject: [PATCH 08/14] fix: Wrap ProviderException as CryptoException to enable key recovery on Android 12+ --- .../authentication/storage/CryptoUtil.java | 30 ++++++++++++++----- 1 file changed, 22 insertions(+), 8 deletions(-) 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 e6a5a000..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 @@ -362,10 +362,17 @@ byte[] RSADecrypt(byte[] encryptedInput) throws IncompatibleDeviceException, Cry throw new IncompatibleDeviceException(e); } catch (ProviderException e) { /* - * On Android 12+ (Keystore2), a padding mismatch throws ProviderException - * instead of InvalidKeyException. This is a KEY incompatibility (stale PKCS1 - * key with OAEP cipher), not a DEVICE incompatibility. Wrapping as CryptoException - * allows the caller to fall through to key regeneration. + * - 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(); @@ -423,10 +430,17 @@ byte[] RSAEncrypt(byte[] decryptedInput) throws IncompatibleDeviceException, Cry throw new IncompatibleDeviceException(e); } catch (ProviderException e) { /* - * On Android 12+ (Keystore2), a padding mismatch throws ProviderException - * instead of InvalidKeyException. This is a KEY incompatibility (stale PKCS1 - * key with OAEP cipher), not a DEVICE incompatibility. Wrapping as CryptoException - * allows the caller to fall through to key regeneration. + * - 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(); From 719cb1c91bb2e1c3bf168ef042718a8a651f6eb7 Mon Sep 17 00:00:00 2001 From: utkrishtS Date: Fri, 6 Mar 2026 10:47:17 +0530 Subject: [PATCH 09/14] fix: Revert sample app changes --- .../com/auth0/sample/DatabaseLoginFragment.kt | 64 ++----------------- .../res/layout/fragment_database_login.xml | 18 +----- 2 files changed, 6 insertions(+), 76 deletions(-) diff --git a/sample/src/main/java/com/auth0/sample/DatabaseLoginFragment.kt b/sample/src/main/java/com/auth0/sample/DatabaseLoginFragment.kt index 81f01c7a..918de531 100644 --- a/sample/src/main/java/com/auth0/sample/DatabaseLoginFragment.kt +++ b/sample/src/main/java/com/auth0/sample/DatabaseLoginFragment.kt @@ -2,7 +2,6 @@ package com.auth0.sample import android.os.Bundle import android.os.CancellationSignal -import android.util.Log import android.view.LayoutInflater import android.view.View import android.view.ViewGroup @@ -51,11 +50,6 @@ import java.util.concurrent.Executors */ class DatabaseLoginFragment : Fragment() { - companion object { - private const val TAG = "Auth0CrashRepro" - private const val SDK_VERSION = "3.13.0 (OAEP)" - } - private val scope = "openid profile email read:current_user update:current_user_metadata" private val account: Auth0 by lazy { @@ -128,8 +122,6 @@ class DatabaseLoginFragment : Fragment() { inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View { val binding = FragmentDatabaseLoginBinding.inflate(inflater, container, false) - binding.tvSdkVersion.text = "SDK: $SDK_VERSION" - Log.i(TAG, "=== App started with SDK version: $SDK_VERSION ===") binding.btLogin.setOnClickListener { val email = binding.textEmail.text.toString() val password = binding.textPassword.text.toString() @@ -223,7 +215,6 @@ class DatabaseLoginFragment : Fragment() { } private suspend fun dbLoginAsync(email: String, password: String) { - Log.i(TAG, ">>> dbLoginAsync() called with email=$email") try { val result = authenticationApiClient.login(email, password, "Username-Password-Authentication") @@ -231,21 +222,7 @@ class DatabaseLoginFragment : Fragment() { .addParameter("scope", scope) .addParameter("audience", audience) .await() - Log.i(TAG, ">>> dbLoginAsync SUCCESS - got credentials for ${result.user.name}") - Log.i(TAG, ">>> Calling credentialsManager.saveCredentials()...") - try { - credentialsManager.saveCredentials(result) - Log.i(TAG, ">>> credentialsManager.saveCredentials() OK") - } catch (e: Exception) { - Log.e(TAG, ">>> credentialsManager.saveCredentials() FAILED", e) - } - Log.i(TAG, ">>> Calling secureCredentialsManager.saveCredentials()...") - try { - secureCredentialsManager.saveCredentials(result) - Log.i(TAG, ">>> secureCredentialsManager.saveCredentials() OK") - } catch (e: Exception) { - Log.e(TAG, ">>> secureCredentialsManager.saveCredentials() FAILED", e) - } + credentialsManager.saveCredentials(result) Snackbar.make( requireView(), "Hello ${result.user.name}", @@ -253,40 +230,24 @@ class DatabaseLoginFragment : Fragment() { ) .show() } catch (error: AuthenticationException) { - Log.e(TAG, ">>> dbLoginAsync FAILED", error) Snackbar.make(requireView(), error.getDescription(), Snackbar.LENGTH_LONG).show() } } private fun dbLogin(email: String, password: String) { - Log.i(TAG, ">>> dbLogin() called with email=$email") authenticationApiClient.login(email, password, "Username-Password-Authentication") .validateClaims() .addParameter("scope", scope) .addParameter("audience", audience) + //Additional customization to the request goes here .start(object : Callback { override fun onFailure(error: AuthenticationException) { - Log.e(TAG, ">>> dbLogin FAILED", error) Snackbar.make(requireView(), error.getDescription(), Snackbar.LENGTH_LONG) .show() } override fun onSuccess(result: Credentials) { - Log.i(TAG, ">>> dbLogin SUCCESS - got credentials for ${result.user.name}") - Log.i(TAG, ">>> Calling credentialsManager.saveCredentials()...") - try { - credentialsManager.saveCredentials(result) - Log.i(TAG, ">>> credentialsManager.saveCredentials() OK") - } catch (e: Exception) { - Log.e(TAG, ">>> credentialsManager.saveCredentials() FAILED", e) - } - Log.i(TAG, ">>> Calling secureCredentialsManager.saveCredentials()...") - try { - secureCredentialsManager.saveCredentials(result) - Log.i(TAG, ">>> secureCredentialsManager.saveCredentials() OK") - } catch (e: Exception) { - Log.e(TAG, ">>> secureCredentialsManager.saveCredentials() FAILED", e) - } + credentialsManager.saveCredentials(result) Snackbar.make( requireView(), "Hello ${result.user.name}", @@ -297,28 +258,14 @@ class DatabaseLoginFragment : Fragment() { } private fun webAuth() { - Log.i(TAG, ">>> webAuth() called - starting browser login") WebAuthProvider.login(account) .withScheme(getString(R.string.com_auth0_scheme)) .withAudience(audience) .withScope(scope) .start(requireContext(), object : Callback { override fun onSuccess(result: Credentials) { - Log.i(TAG, ">>> webAuth SUCCESS - got credentials for ${result.user.name}") - Log.i(TAG, ">>> Calling credentialsManager.saveCredentials()...") - try { - credentialsManager.saveCredentials(result) - Log.i(TAG, ">>> credentialsManager.saveCredentials() OK") - } catch (e: Exception) { - Log.e(TAG, ">>> credentialsManager.saveCredentials() FAILED", e) - } - Log.i(TAG, ">>> Calling secureCredentialsManager.saveCredentials()...") - try { - secureCredentialsManager.saveCredentials(result) - Log.i(TAG, ">>> secureCredentialsManager.saveCredentials() OK") - } catch (e: Exception) { - Log.e(TAG, ">>> secureCredentialsManager.saveCredentials() FAILED", e) - } + credentialsManager.saveCredentials(result) + secureCredentialsManager.saveCredentials(result) Snackbar.make( requireView(), "Hello ${result.user.name}", @@ -327,7 +274,6 @@ class DatabaseLoginFragment : Fragment() { } override fun onFailure(error: AuthenticationException) { - Log.e(TAG, ">>> webAuth FAILED", error) val message = if (error.isCanceled) "Browser was closed" diff --git a/sample/src/main/res/layout/fragment_database_login.xml b/sample/src/main/res/layout/fragment_database_login.xml index 96271d2d..5d3731b2 100644 --- a/sample/src/main/res/layout/fragment_database_login.xml +++ b/sample/src/main/res/layout/fragment_database_login.xml @@ -46,22 +46,6 @@ app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toBottomOf="@+id/textEmail" /> - - + app:layout_constraintTop_toTopOf="parent" /> Date: Fri, 6 Mar 2026 23:26:38 +0530 Subject: [PATCH 10/14] doc: remove EA note for DPoP (#933) --- EXAMPLES.md | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/EXAMPLES.md b/EXAMPLES.md index 1ad204be..d7dbbad3 100644 --- a/EXAMPLES.md +++ b/EXAMPLES.md @@ -11,7 +11,7 @@ - [Changing the Return To URL scheme](#changing-the-return-to-url-scheme) - [Specify a Custom Logout URL](#specify-a-custom-logout-url) - [Trusted Web Activity](#trusted-web-activity) - - [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) @@ -30,7 +30,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) @@ -228,10 +228,7 @@ WebAuthProvider.login(account) .await(this) ``` -## 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()` method. @@ -1606,10 +1603,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. From 9506d952aad1c566fd71c65204243c57d12a8adf Mon Sep 17 00:00:00 2001 From: Prince Mathew <17837162+pmathew92@users.noreply.github.com> Date: Mon, 9 Mar 2026 12:35:40 +0530 Subject: [PATCH 11/14] refactor : Deprecate the existing MFA APIs in `AuthenticationAPIClient` (#932) --- EXAMPLES.md | 2 ++ .../authentication/AuthenticationAPIClient.kt | 17 +++++++++++++++++ 2 files changed, 19 insertions(+) diff --git a/EXAMPLES.md b/EXAMPLES.md index d7dbbad3..a6bb1a61 100644 --- a/EXAMPLES.md +++ b/EXAMPLES.md @@ -423,6 +423,8 @@ authentication > The default scope used is `openid profile email`. Regardless of the scopes set to the request, the `openid` scope is always enforced. +> **Note** : The MFA APIs in Authentication client has been deprecated. Use the new MFA Flexible Factors APIs + ### MFA Flexible Factors Grant > [!IMPORTANT] 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, From f249712d27c88e308fda6712014f6c72657f4bc9 Mon Sep 17 00:00:00 2001 From: utkrishtS Date: Tue, 10 Mar 2026 15:58:30 +0530 Subject: [PATCH 12/14] adding UT cases as per review comments --- .../storage/CryptoUtilTest.java | 169 +++++++++++++++++- 1 file changed, 168 insertions(+), 1 deletion(-) 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 55b1f507..7e53a896 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 @@ -1998,7 +1998,8 @@ public void shouldTriggerPKCS1MigrationWhenRSADecryptThrowsProviderException() t PowerMockito.when(Base64.encode(reEncryptedOAEP, Base64.DEFAULT)) .thenReturn(encodedOAEP.getBytes(StandardCharsets.UTF_8)); - doThrow(new IncompatibleDeviceException( + 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); @@ -2082,4 +2083,170 @@ public void shouldDeleteStaleRSAKeyAndRethrowOnIncompatibleDeviceExceptionDuring 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); + PowerMockito.mockStatic(Base64.class); + PowerMockito.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"; + PowerMockito.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); + + PowerMockito.mockStatic(Base64.class); + PowerMockito.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"; + PowerMockito.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); + PowerMockito.mockStatic(Base64.class); + PowerMockito.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"; + PowerMockito.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); + PowerMockito.mockStatic(Base64.class); + PowerMockito.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"; + PowerMockito.when(Base64.encode(encryptedNewKey, Base64.DEFAULT)) + .thenReturn(encodedNewKey.getBytes(StandardCharsets.UTF_8)); + + byte[] result = cryptoUtil.getAESKey(); + + assertThat(result, is(notNullValue())); + assertThat(result, is(newAESKey)); + + } } From 10478a7a27ad119e2dbb602d2ebc98fc74a6b3ff Mon Sep 17 00:00:00 2001 From: Prince Mathew <17837162+pmathew92@users.noreply.github.com> Date: Wed, 11 Mar 2026 12:27:07 +0530 Subject: [PATCH 13/14] Release 3.14.0 (#935) --- .version | 2 +- CHANGELOG.md | 11 +++++++++++ README.md | 2 +- 3 files changed, 13 insertions(+), 2 deletions(-) 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/README.md b/README.md index 11876052..3add0589 100644 --- a/README.md +++ b/README.md @@ -52,7 +52,7 @@ To install Auth0.Android with [Gradle](https://gradle.org/), simply add the foll ```gradle dependencies { - implementation 'com.auth0.android:auth0:3.13.0' + implementation 'com.auth0.android:auth0:3.14.0' } ``` From 8b42d6d76ce380d3fb6217d5a7a8781747bf62c4 Mon Sep 17 00:00:00 2001 From: Prince Mathew Date: Thu, 12 Mar 2026 14:16:09 +0530 Subject: [PATCH 14/14] Added missing CryptoUtil test cases --- .../storage/CryptoUtilTest.java | 294 ++++++++++++++++++ 1 file changed, 294 insertions(+) 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 */