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/60] 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 cae42b8ca..1ad204be4 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 eec6bd1fb..312377309 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 31f8e62f4..0cc5c61fa 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 a6e86c492..4a367e778 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/60] 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 d2183bca9..000000000 --- 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/60] =?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 e0b175e88..ef31c448f 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 d9f84c910..e26104b00 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/60] 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 c6087705f..d22503a63 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 fa5c2328a..a8b9447c7 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 8fec80b5f..acd415cd1 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 342cf3b34..2d4beb419 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/60] 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 b55195740..1d343e61c 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/60] 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 c74da0946..96cc52d96 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 43f23af44..4967e3640 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/60] 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 ef31c448f..e6a5a0006 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 e26104b00..55b1f507c 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 918de531b..81f01c7a3 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 5d3731b25..96271d2dc 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/60] 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 e6a5a0006..4ac745467 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/60] 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 81f01c7a3..918de531b 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 96271d2dc..5d3731b25 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/60] 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 1ad204be4..d7dbbad3a 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/60] 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 d7dbbad3a..a6bb1a615 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 312377309..572ecc513 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/60] 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 55b1f507c..7e53a8962 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/60] 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 77fdc6bb0..12566ed7f 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 e3c92be3f..2d25046e0 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 118760528..3add05892 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 260dfb71d31ca5c3561e771ea6f38cfccf317bc1 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 25 Mar 2026 10:34:36 +0530 Subject: [PATCH 14/60] chore(deps): bump codecov/codecov-action from 5.5.2 to 5.5.3 (#938) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 3f6a5ea5e..001273b3c 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -29,4 +29,4 @@ jobs: - run: ./gradlew clean test jacocoTestReport lint --continue --console=plain --max-workers=1 --no-daemon - - uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # pin@5.5.2 + - uses: codecov/codecov-action@1af58845a975a7985b0beb0cbe6fbbb71a41dbad # pin@5.5.3 From 91435931889acdded3e9a5810ee6190b0a052939 Mon Sep 17 00:00:00 2001 From: Prince Mathew <17837162+pmathew92@users.noreply.github.com> Date: Thu, 26 Mar 2026 11:23:16 +0530 Subject: [PATCH 15/60] refactor : Handling more exceptions scenario in DPoP key generation (#943) --- .../com/auth0/android/dpop/DPoPKeyStore.kt | 36 +++++++-- .../auth0/android/dpop/DPoPKeyStoreTest.kt | 75 +++++++++++++++++++ 2 files changed, 104 insertions(+), 7 deletions(-) diff --git a/auth0/src/main/java/com/auth0/android/dpop/DPoPKeyStore.kt b/auth0/src/main/java/com/auth0/android/dpop/DPoPKeyStore.kt index 742e5beaf..455651370 100644 --- a/auth0/src/main/java/com/auth0/android/dpop/DPoPKeyStore.kt +++ b/auth0/src/main/java/com/auth0/android/dpop/DPoPKeyStore.kt @@ -6,19 +6,21 @@ import android.os.Build import android.security.keystore.KeyGenParameterSpec import android.security.keystore.KeyProperties import android.util.Log +import java.io.IOException import java.security.InvalidAlgorithmParameterException import java.security.KeyPairGenerator import java.security.KeyStore import java.security.KeyStoreException import java.security.NoSuchAlgorithmException import java.security.NoSuchProviderException +import java.security.UnrecoverableKeyException import java.security.PrivateKey import java.security.ProviderException import java.security.PublicKey +import java.security.cert.CertificateException import java.security.spec.ECGenParameterSpec import java.util.Calendar import javax.security.auth.x500.X500Principal -import javax.security.cert.CertificateException /** * Class to handle all DPoP related keystore operations @@ -97,8 +99,16 @@ internal open class DPoPKeyStore { if (publicKey != null) { return Pair(privateKey, publicKey) } - } catch (e: KeyStoreException) { - throw DPoPException(DPoPException.Code.KEY_STORE_ERROR, e) + } catch (e: Exception) { + when (e) { + is KeyStoreException, + is NoSuchAlgorithmException, + is UnrecoverableKeyException, + is ClassCastException, + is IOException, + is CertificateException -> throw DPoPException(DPoPException.Code.KEY_STORE_ERROR, e) + else -> throw DPoPException(DPoPException.Code.UNKNOWN_ERROR, e) + } } Log.d(TAG, "Returning null key pair ") return null @@ -107,16 +117,28 @@ internal open class DPoPKeyStore { fun hasKeyPair(): Boolean { try { return keyStore.containsAlias(KEY_ALIAS) - } catch (e: KeyStoreException) { - throw DPoPException(DPoPException.Code.KEY_STORE_ERROR, e) + } catch (e: Exception) { + when (e) { + is KeyStoreException, + is NoSuchAlgorithmException, + is IOException, + is CertificateException -> throw DPoPException(DPoPException.Code.KEY_STORE_ERROR, e) + else -> throw DPoPException(DPoPException.Code.UNKNOWN_ERROR, e) + } } } fun deleteKeyPair() { try { keyStore.deleteEntry(KEY_ALIAS) - } catch (e: KeyStoreException) { - throw DPoPException(DPoPException.Code.KEY_STORE_ERROR, e) + } catch (e: Exception) { + when (e) { + is KeyStoreException, + is NoSuchAlgorithmException, + is IOException, + is CertificateException -> throw DPoPException(DPoPException.Code.KEY_STORE_ERROR, e) + else -> throw DPoPException(DPoPException.Code.UNKNOWN_ERROR, e) + } } } diff --git a/auth0/src/test/java/com/auth0/android/dpop/DPoPKeyStoreTest.kt b/auth0/src/test/java/com/auth0/android/dpop/DPoPKeyStoreTest.kt index c8a8c6a4c..5d4f83e52 100644 --- a/auth0/src/test/java/com/auth0/android/dpop/DPoPKeyStoreTest.kt +++ b/auth0/src/test/java/com/auth0/android/dpop/DPoPKeyStoreTest.kt @@ -32,9 +32,11 @@ import java.security.InvalidAlgorithmParameterException import java.security.KeyPairGenerator import java.security.KeyStore import java.security.KeyStoreException +import java.security.NoSuchAlgorithmException import java.security.PrivateKey import java.security.ProviderException import java.security.PublicKey +import java.security.UnrecoverableKeyException import java.security.cert.Certificate import javax.security.auth.x500.X500Principal @@ -191,6 +193,54 @@ public class DPoPKeyStoreTest { assertThat(exception.cause, `is`(cause)) } + @Test + public fun `getKeyPair should throw KEY_STORE_ERROR on NoSuchAlgorithmException`() { + val cause = NoSuchAlgorithmException("Test Exception") + whenever(mockKeyStore.getKey(any(), anyOrNull())).thenThrow(cause) + + val exception = assertThrows(DPoPException::class.java) { + dpopKeyStore.getKeyPair() + } + assertEquals(exception.message, DPoPException.KEY_STORE_ERROR.message) + assertThat(exception.cause, `is`(cause)) + } + + @Test + public fun `getKeyPair should throw KEY_STORE_ERROR on UnrecoverableKeyException`() { + val cause = UnrecoverableKeyException("Test Exception") + whenever(mockKeyStore.getKey(any(), anyOrNull())).thenThrow(cause) + + val exception = assertThrows(DPoPException::class.java) { + dpopKeyStore.getKeyPair() + } + assertEquals(exception.message, DPoPException.KEY_STORE_ERROR.message) + assertThat(exception.cause, `is`(cause)) + } + + @Test + public fun `getKeyPair should throw KEY_STORE_ERROR on ClassCastException`() { + val cause = ClassCastException("Test Exception") + whenever(mockKeyStore.getKey(any(), anyOrNull())).thenThrow(cause) + + val exception = assertThrows(DPoPException::class.java) { + dpopKeyStore.getKeyPair() + } + assertEquals(exception.message, DPoPException.KEY_STORE_ERROR.message) + assertThat(exception.cause, `is`(cause)) + } + + @Test + public fun `getKeyPair should throw UNKNOWN_ERROR on unhandled exception`() { + val cause = RuntimeException("Unexpected error") + whenever(mockKeyStore.getKey(any(), anyOrNull())).thenThrow(cause) + + val exception = assertThrows(DPoPException::class.java) { + dpopKeyStore.getKeyPair() + } + assertEquals(exception.message, DPoPException.UNKNOWN_ERROR.message) + assertThat(exception.cause, `is`(cause)) + } + @Test public fun `hasKeyPair should return true when alias exists`() { whenever(mockKeyStore.containsAlias(any())).thenReturn(true) @@ -217,6 +267,18 @@ public class DPoPKeyStoreTest { assertThat(exception.cause, `is`(cause)) } + @Test + public fun `hasKeyPair should throw UNKNOWN_ERROR on unhandled exception`() { + val cause = RuntimeException("Unexpected error") + whenever(mockKeyStore.containsAlias(any())).thenThrow(cause) + + val exception = assertThrows(DPoPException::class.java) { + dpopKeyStore.hasKeyPair() + } + assertEquals(exception.message, DPoPException.UNKNOWN_ERROR.message) + assertThat(exception.cause, `is`(cause)) + } + @Test public fun `deleteKeyPair should call deleteEntry`() { dpopKeyStore.deleteKeyPair() @@ -235,6 +297,19 @@ public class DPoPKeyStoreTest { assertThat(exception.cause, `is`(cause)) } + + @Test + public fun `deleteKeyPair should throw UNKNOWN_ERROR on unhandled exception`() { + val cause = RuntimeException("Unexpected error") + whenever(mockKeyStore.deleteEntry(any())).thenThrow(cause) + + val exception = assertThrows(DPoPException::class.java) { + dpopKeyStore.deleteKeyPair() + } + assertEquals(exception.message, DPoPException.UNKNOWN_ERROR.message) + assertThat(exception.cause, `is`(cause)) + } + @Test public fun `generateKeyPair should retry without StrongBox when ProviderException occurs with StrongBox enabled`() { val providerException = ProviderException("StrongBox attestation failed") From d4fa009123adf1efed56ddd5d1cece99d527a8ea Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 27 Mar 2026 16:53:10 +0530 Subject: [PATCH 16/60] chore(deps): bump codecov/codecov-action from 5.5.3 to 6.0.0 (#944) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 001273b3c..d13ae647d 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -29,4 +29,4 @@ jobs: - run: ./gradlew clean test jacocoTestReport lint --continue --console=plain --max-workers=1 --no-daemon - - uses: codecov/codecov-action@1af58845a975a7985b0beb0cbe6fbbb71a41dbad # pin@5.5.3 + - uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # pin@6.0.0 From 5c371a56cb40df6ea3e93e5b5ae2b1e5661010ad Mon Sep 17 00:00:00 2001 From: Prince Mathew <17837162+pmathew92@users.noreply.github.com> Date: Mon, 6 Apr 2026 15:32:27 +0530 Subject: [PATCH 17/60] refactor : Made the updateAuthenticationMethodById public (#948) --- EXAMPLES.md | 37 +++++++++++++++++++ .../android/myaccount/MyAccountAPIClient.kt | 4 +- 2 files changed, 39 insertions(+), 2 deletions(-) diff --git a/EXAMPLES.md b/EXAMPLES.md index a6bb1a615..12e2e4bca 100644 --- a/EXAMPLES.md +++ b/EXAMPLES.md @@ -43,6 +43,7 @@ - [Enroll a Recovery Code](#enroll-a-recovery-code) - [Verify an Enrollment](#verify-an-enrollment) - [Delete an Authentication Method](#delete-an-authentication-method) + - [Update an Authentication Method](#update-an-authentication-method) - [Credentials Manager](#credentials-manager) - [Secure Credentials Manager](#secure-credentials-manager) - [Usage](#usage) @@ -2239,6 +2240,42 @@ myAccountClient.deleteAuthenticationMethod("phone|dev_...") + +### Update an Authentication Method +**Scopes required:** `update:me:authentication_methods` + +Updates a single authentication method. + +**Prerequisites:** + +The user must have the specific authentication method (identified by its ID) already enrolled. + +```kotlin +myAccountClient.updateAuthenticationMethodById("{Authentication_Id}", "{Name}") + .start(object : Callback { + override fun onSuccess(result: Unit) { + // Deletion successful + } + override fun onFailure(error: MyAccountException) { } + }) +``` +
+ Using Java + +```java +myAccountClient.updateAuthenticationMethodById("{Authentication_Id}", "{Name}") + .start(new Callback() { + @Override + public void onSuccess(Void result) { + // Deletion successful + } + @Override + public void onFailure(@NonNull MyAccountException error) { } + }); +``` +
+ + ## Credentials Manager ### Secure Credentials Manager 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 a8b9447c7..0a71b1f66 100644 --- a/auth0/src/main/java/com/auth0/android/myaccount/MyAccountAPIClient.kt +++ b/auth0/src/main/java/com/auth0/android/myaccount/MyAccountAPIClient.kt @@ -364,13 +364,13 @@ public class MyAccountAPIClient @VisibleForTesting(otherwise = VisibleForTesting * }) * ``` * - * @param authenticationMethodId Id of the authentication method to be retrieved + * @param authenticationMethodId Id of the authentication method to be updated * @param authenticationMethodName The friendly name of the authentication method * @param preferredAuthenticationMethod The preferred authentication method for the user. (for phone authenticators) * */ @JvmOverloads - internal fun updateAuthenticationMethodById( + public fun updateAuthenticationMethodById( authenticationMethodId: String, authenticationMethodName: String? = null, preferredAuthenticationMethod: PhoneAuthenticationMethodType? = null From 5995f3643328db8d63c6bc8d4bac0a2f050fee61 Mon Sep 17 00:00:00 2001 From: Prince Mathew <17837162+pmathew92@users.noreply.github.com> Date: Wed, 15 Apr 2026 12:06:57 +0530 Subject: [PATCH 18/60] feat: Add three new DPoP errors to `CredentialsManagerException` (#949) --- EXAMPLES.md | 57 ++- .../authentication/AuthenticationAPIClient.kt | 7 + .../storage/BaseCredentialsManager.kt | 96 +++++ .../storage/CredentialsManager.kt | 21 +- .../storage/CredentialsManagerException.kt | 13 + .../storage/SecureCredentialsManager.kt | 30 +- .../main/java/com/auth0/android/dpop/DPoP.kt | 23 ++ .../storage/CredentialsManagerTest.kt | 389 ++++++++++++++++-- .../storage/SecureCredentialsManagerTest.kt | 276 ++++++++++++- 9 files changed, 860 insertions(+), 52 deletions(-) diff --git a/EXAMPLES.md b/EXAMPLES.md index 12e2e4bca..da6c10333 100644 --- a/EXAMPLES.md +++ b/EXAMPLES.md @@ -280,6 +280,21 @@ if (DPoP.isNonceRequiredError(response)) { } ``` +When using DPoP with `CredentialsManager` or `SecureCredentialsManager`, the `AuthenticationAPIClient` passed to the credentials manager **must** also have DPoP enabled. Otherwise, token refresh requests will be sent without the DPoP proof and the SDK will throw a `CredentialsManagerException.DPOP_NOT_CONFIGURED` error. + +```kotlin + +val auth0 = Auth0.getInstance("YOUR_CLIENT_ID", "YOUR_DOMAIN") +val apiClient = AuthenticationAPIClient(auth0).useDPoP(context) // DPoP enabled +val storage = SharedPreferencesStorage(context) +val manager = CredentialsManager(apiClient, storage) + +WebAuthProvider + .useDPoP() + .login(auth0) + .start(context, callback) +``` + On logout, you should call `DPoP.clearKeyPair()` to delete the user's key pair from the Keychain. ```kotlin @@ -293,7 +308,7 @@ WebAuthProvider.logout(account) }) ``` -> [!NOTE] +> [!NOTE] > DPoP is supported only on Android version 6.0 (API level 23) and above. Trying to use DPoP in any older versions will result in an exception. ## Authentication API @@ -1662,11 +1677,21 @@ val auth0 = Auth0.getInstance("YOUR_CLIENT_ID", "YOUR_DOMAIN") val apiClient = AuthenticationAPIClient(auth0).useDPoP(this) val storage = SharedPreferencesStorage(this) val manager = SecureCredentialsManager(apiClient, this, auth0, storage) +``` + +Similarly, for `CredentialsManager`: +```kotlin +val auth0 = Auth0.getInstance("YOUR_CLIENT_ID", "YOUR_DOMAIN") +val apiClient = AuthenticationAPIClient(auth0).useDPoP(this) +val storage = SharedPreferencesStorage(this) +val manager = CredentialsManager(apiClient, storage) ``` +> [!IMPORTANT] +> When credentials are DPoP-bound, the SDK validates the DPoP key state before each token refresh. If the DPoP key pair is lost, the SDK will throw `CredentialsManagerException.DPOP_KEY_MISSING` and the user must re-authenticate. If the key pair has changed since the credentials were saved, the SDK will throw `CredentialsManagerException.DPOP_KEY_MISMATCH`. If the `AuthenticationAPIClient` was not configured with `useDPoP()`, the SDK will throw `CredentialsManagerException.DPOP_NOT_CONFIGURED`. -> [!NOTE] +> [!NOTE] > DPoP is supported only on Android version 6.0 (API level 23) and above. Trying to use DPoP in any older versions will result in an exception. @@ -2587,24 +2612,42 @@ In the event that something happened while trying to save or retrieve the creden - Tokens have expired but no `refresh_token` is available to perform a refresh credentials request. - Device's Lock Screen security settings have changed (e.g. the PIN code was changed). Even when `hasCredentials` returns true, the encryption keys will be deemed invalid and until `saveCredentials` is called again it won't be possible to decrypt any previously existing content, since they keys used back then are not the same as the new ones. - Device is not compatible with some of the algorithms required by the `SecureCredentialsManager` class. This is considered a catastrophic event and might happen when the OEM has modified the Android ROM removing some of the officially included algorithms. Nevertheless, it can be checked in the exception instance itself by calling `isDeviceIncompatible`. By doing so you can decide the fallback for storing the credentials, such as using the regular `CredentialsManager`. +- **DPoP key pair lost** — The DPoP key pair is no longer available in the Android KeyStore. The stored credentials are cleared and re-authentication is required. +- **DPoP key pair mismatch** — The DPoP key pair exists but is different from the one used when the credentials were saved. The stored credentials are cleared and re-authentication is required. +- **DPoP not configured** — The stored credentials are DPoP-bound but the `AuthenticationAPIClient` used by the credentials manager was not configured with `useDPoP(context)`. The developer needs to call `AuthenticationAPIClient(auth0).useDPoP(context)` and pass the configured client to the credentials manager. -You can access the `code` property of the `CredentialsManagerException` to understand why the operation with `CredentialsManager` has failed and the `message` property of the `CredentialsManagerException` would give you a description of the exception. +You can access the `code` property of the `CredentialsManagerException` to understand why the operation with `CredentialsManager` has failed and the `message` property of the `CredentialsManagerException` would give you a description of the exception. -Starting from version `3.0.0` you can even pass the exception to a `when` expression and handle the exception accordingly in your app's logic as shown in the below code snippet: +Starting from version `3.0.0` you can even pass the exception to a `when` expression and handle the exception accordingly in your app's logic as shown in the below code snippet: ```kotlin when(credentialsManagerException) { - CredentialsManagerException.NO_CREDENTIALS - > { + CredentialsManagerException.NO_CREDENTIALS -> { // handle no credentials scenario } - CredentialsManagerException.NO_REFRESH_TOKEN - > { + CredentialsManagerException.NO_REFRESH_TOKEN -> { // handle no refresh token scenario } - CredentialsManagerException.STORE_FAILED - > { + CredentialsManagerException.STORE_FAILED -> { // handle store failed scenario } + + CredentialsManagerException.DPOP_KEY_MISSING -> { + // DPoP key was lost + // Clear local state and prompt user to re-authenticate + } + + CredentialsManagerException.DPOP_KEY_MISMATCH -> { + // DPoP key exists but doesn't match the one used at login (key rotation) + // Clear local state and prompt user to re-authenticate + } + + CredentialsManagerException.DPOP_NOT_CONFIGURED -> { + // Developer forgot to call useDPoP() on the AuthenticationAPIClient + // passed to the credentials manager. Fix the client configuration. + } // ... similarly for other error codes } ``` diff --git a/auth0/src/main/java/com/auth0/android/authentication/AuthenticationAPIClient.kt b/auth0/src/main/java/com/auth0/android/authentication/AuthenticationAPIClient.kt index 572ecc513..f33aee9ca 100755 --- a/auth0/src/main/java/com/auth0/android/authentication/AuthenticationAPIClient.kt +++ b/auth0/src/main/java/com/auth0/android/authentication/AuthenticationAPIClient.kt @@ -55,6 +55,13 @@ public class AuthenticationAPIClient @VisibleForTesting(otherwise = VisibleForTe private var dPoP: DPoP? = null + /** + * Returns whether DPoP (Demonstrating Proof of Possession) is enabled on this client. + * DPoP is enabled by calling [useDPoP]. + */ + public val isDPoPEnabled: Boolean + get() = dPoP != null + /** * Creates a new API client instance providing Auth0 account info. * diff --git a/auth0/src/main/java/com/auth0/android/authentication/storage/BaseCredentialsManager.kt b/auth0/src/main/java/com/auth0/android/authentication/storage/BaseCredentialsManager.kt index d3ac32d59..be2aa1738 100644 --- a/auth0/src/main/java/com/auth0/android/authentication/storage/BaseCredentialsManager.kt +++ b/auth0/src/main/java/com/auth0/android/authentication/storage/BaseCredentialsManager.kt @@ -4,6 +4,8 @@ import android.util.Log import androidx.annotation.VisibleForTesting import com.auth0.android.authentication.AuthenticationAPIClient import com.auth0.android.callback.Callback +import com.auth0.android.dpop.DPoPException +import com.auth0.android.dpop.DPoPUtil import com.auth0.android.result.APICredentials import com.auth0.android.result.Credentials import com.auth0.android.result.SSOCredentials @@ -20,6 +22,14 @@ public abstract class BaseCredentialsManager internal constructor( protected val storage: Storage, private val jwtDecoder: JWTDecoder ) { + + internal companion object { + internal const val KEY_DPOP_THUMBPRINT = "com.auth0.dpop_key_thumbprint" + + @VisibleForTesting(otherwise = VisibleForTesting.PACKAGE_PRIVATE) + internal const val KEY_TOKEN_TYPE = "com.auth0.token_type" + } + private var _clock: Clock = ClockImpl() /** @@ -155,6 +165,92 @@ public abstract class BaseCredentialsManager internal constructor( internal val currentTimeInMillis: Long get() = _clock.getCurrentTimeMillis() + /** + * Stores the DPoP key thumbprint if DPoP was used for this credential set. + * Uses a dual strategy to store the thumbprint: + * - credentials.type == "DPoP" when server confirms DPoP but client lacks useDPoP() + * - isDPoPEnabled catches the case where client used DPoP, server returned token_type: "Bearer" + */ + protected fun saveDPoPThumbprint(credentials: Credentials) { + val dpopUsed = credentials.type.equals("DPoP", ignoreCase = true) + || authenticationClient.isDPoPEnabled + + if (!dpopUsed) { + storage.remove(KEY_DPOP_THUMBPRINT) + return + } + + val thumbprint = try { + if (DPoPUtil.hasKeyPair()) DPoPUtil.getPublicKeyJWK() else null + } catch (e: DPoPException) { + Log.w(this::class.java.simpleName, "Failed to fetch DPoP key thumbprint", e) + null + } + + if (thumbprint != null) { + storage.store(KEY_DPOP_THUMBPRINT, thumbprint) + } else { + storage.remove(KEY_DPOP_THUMBPRINT) + } + } + + /** + * Validates DPoP key/token alignment before attempting a refresh. + * + * Uses two signals to detect DPoP-bound credentials: + * - tokenType == "DPoP" + * - KEY_DPOP_THUMBPRINT exists + * + * @param tokenType the token_type value from storage (or decrypted credentials for migration) + * @return null if validation passes, or a CredentialsManagerException if it fails + */ + protected fun validateDPoPState(tokenType: String?): CredentialsManagerException? { + val storedThumbprint = storage.retrieveString(KEY_DPOP_THUMBPRINT) + val isDPoPBound = (tokenType?.equals("DPoP", ignoreCase = true) == true) + || (storedThumbprint != null) + if (!isDPoPBound) return null + + // Check 1: Does the DPoP key still exist in KeyStore? + val hasKey = try { + DPoPUtil.hasKeyPair() + } catch (e: DPoPException) { + Log.e(this::class.java.simpleName, "Failed to check DPoP key existence", e) + false + } + if (!hasKey) { + Log.w(this::class.java.simpleName, "DPoP key missing from KeyStore. Clearing stale credentials.") + clearCredentials() + return CredentialsManagerException(CredentialsManagerException.Code.DPOP_KEY_MISSING) + } + + // Check 2: Is the AuthenticationAPIClient configured with DPoP? + if (!authenticationClient.isDPoPEnabled) { + return CredentialsManagerException(CredentialsManagerException.Code.DPOP_NOT_CONFIGURED) + } + + // Check 3: Does the current key match the one used when credentials were saved? + val currentThumbprint = try { + DPoPUtil.getPublicKeyJWK() + } catch (e: DPoPException) { + Log.e(this::class.java.simpleName, "Failed to read DPoP key thumbprint", e) + null + } + + if (storedThumbprint != null) { + if (currentThumbprint != storedThumbprint) { + Log.w(this::class.java.simpleName, "DPoP key thumbprint mismatch. The key pair has changed since credentials were saved. Clearing stale credentials.") + clearCredentials() + return CredentialsManagerException(CredentialsManagerException.Code.DPOP_KEY_MISMATCH) + } + } else if (currentThumbprint != null) { + // Migration: existing DPoP user upgraded — no thumbprint stored yet. + // Backfill so future checks can detect key rotation. + storage.store(KEY_DPOP_THUMBPRINT, currentThumbprint) + } + + return null + } + /** * Checks if the stored scope is the same as the requested one. * diff --git a/auth0/src/main/java/com/auth0/android/authentication/storage/CredentialsManager.kt b/auth0/src/main/java/com/auth0/android/authentication/storage/CredentialsManager.kt index 0cc5c61fa..f9a56919b 100644 --- a/auth0/src/main/java/com/auth0/android/authentication/storage/CredentialsManager.kt +++ b/auth0/src/main/java/com/auth0/android/authentication/storage/CredentialsManager.kt @@ -75,6 +75,7 @@ public class CredentialsManager @VisibleForTesting(otherwise = VisibleForTesting storage.store(KEY_EXPIRES_AT, credentials.expiresAt.time) storage.store(KEY_SCOPE, credentials.scope) storage.store(LEGACY_KEY_CACHE_EXPIRES_AT, credentials.expiresAt.time) + saveDPoPThumbprint(credentials) } /** @@ -133,6 +134,12 @@ public class CredentialsManager @VisibleForTesting(otherwise = VisibleForTesting return@execute } + val tokenType = storage.retrieveString(KEY_TOKEN_TYPE) + validateDPoPState(tokenType)?.let { dpopError -> + callback.onFailure(dpopError) + return@execute + } + val request = authenticationClient.ssoExchange(refreshToken) try { if (parameters.isNotEmpty()) { @@ -482,6 +489,10 @@ public class CredentialsManager @VisibleForTesting(otherwise = VisibleForTesting callback.onFailure(CredentialsManagerException.NO_REFRESH_TOKEN) return@execute } + validateDPoPState(tokenType)?.let { dpopError -> + callback.onFailure(dpopError) + return@execute + } val request = authenticationClient.renewAuth(refreshToken) request.addParameters(parameters) if (scope != null) { @@ -592,8 +603,10 @@ public class CredentialsManager @VisibleForTesting(otherwise = VisibleForTesting //Check if existing api credentials are present and valid val key = getAPICredentialsKey(audience, scope) val apiCredentialsJson = storage.retrieveString(key) + var apiCredentialType: String? = null apiCredentialsJson?.let { val apiCredentials = gson.fromJson(it, APICredentials::class.java) + apiCredentialType = apiCredentials.type val willTokenExpire = willExpire(apiCredentials.expiresAt.time, minTtl.toLong()) val scopeChanged = hasScopeChanged( @@ -616,6 +629,12 @@ public class CredentialsManager @VisibleForTesting(otherwise = VisibleForTesting return@execute } + val tokenType = apiCredentialType ?: storage.retrieveString(KEY_TOKEN_TYPE) + validateDPoPState(tokenType)?.let { dpopError -> + callback.onFailure(dpopError) + return@execute + } + val request = authenticationClient.renewAuth(refreshToken, audience, scope) request.addParameters(parameters) @@ -714,6 +733,7 @@ public class CredentialsManager @VisibleForTesting(otherwise = VisibleForTesting storage.remove(KEY_EXPIRES_AT) storage.remove(KEY_SCOPE) storage.remove(LEGACY_KEY_CACHE_EXPIRES_AT) + storage.remove(KEY_DPOP_THUMBPRINT) } /** @@ -761,7 +781,6 @@ public class CredentialsManager @VisibleForTesting(otherwise = VisibleForTesting private const val KEY_ACCESS_TOKEN = "com.auth0.access_token" private const val KEY_REFRESH_TOKEN = "com.auth0.refresh_token" private const val KEY_ID_TOKEN = "com.auth0.id_token" - private const val KEY_TOKEN_TYPE = "com.auth0.token_type" private const val KEY_EXPIRES_AT = "com.auth0.expires_at" private const val KEY_SCOPE = "com.auth0.scope" diff --git a/auth0/src/main/java/com/auth0/android/authentication/storage/CredentialsManagerException.kt b/auth0/src/main/java/com/auth0/android/authentication/storage/CredentialsManagerException.kt index 9796dbe64..44c31f7d8 100644 --- a/auth0/src/main/java/com/auth0/android/authentication/storage/CredentialsManagerException.kt +++ b/auth0/src/main/java/com/auth0/android/authentication/storage/CredentialsManagerException.kt @@ -48,6 +48,9 @@ public class CredentialsManagerException : API_ERROR, SSO_EXCHANGE_FAILED, MFA_REQUIRED, + DPOP_KEY_MISSING, + DPOP_KEY_MISMATCH, + DPOP_NOT_CONFIGURED, UNKNOWN_ERROR } @@ -159,6 +162,13 @@ public class CredentialsManagerException : public val MFA_REQUIRED: CredentialsManagerException = CredentialsManagerException(Code.MFA_REQUIRED) + public val DPOP_KEY_MISSING: CredentialsManagerException = + CredentialsManagerException(Code.DPOP_KEY_MISSING) + public val DPOP_KEY_MISMATCH: CredentialsManagerException = + CredentialsManagerException(Code.DPOP_KEY_MISMATCH) + public val DPOP_NOT_CONFIGURED: CredentialsManagerException = + CredentialsManagerException(Code.DPOP_NOT_CONFIGURED) + public val UNKNOWN_ERROR: CredentialsManagerException = CredentialsManagerException(Code.UNKNOWN_ERROR) @@ -207,6 +217,9 @@ public class CredentialsManagerException : Code.API_ERROR -> "An error occurred while processing the request." Code.SSO_EXCHANGE_FAILED ->"The exchange of the refresh token for SSO credentials failed." Code.MFA_REQUIRED -> "Multi-factor authentication is required to complete the credential renewal." + Code.DPOP_KEY_MISSING -> "The stored credentials are DPoP-bound but the DPoP key pair is no longer available in the Android KeyStore. Re-authentication is required." + Code.DPOP_KEY_MISMATCH -> "The stored credentials are DPoP-bound but the current DPoP key pair does not match the one used when credentials were saved. Re-authentication is required." + Code.DPOP_NOT_CONFIGURED -> "The stored credentials are DPoP-bound but the AuthenticationAPIClient used by this credentials manager was not configured with useDPoP(context). Call AuthenticationAPIClient(auth0).useDPoP(context) and pass the configured client to the credentials manager." Code.UNKNOWN_ERROR -> "An unknown error has occurred while fetching the token. Please check the error cause for more details." } } diff --git a/auth0/src/main/java/com/auth0/android/authentication/storage/SecureCredentialsManager.kt b/auth0/src/main/java/com/auth0/android/authentication/storage/SecureCredentialsManager.kt index 4a367e778..ef51b4d7a 100644 --- a/auth0/src/main/java/com/auth0/android/authentication/storage/SecureCredentialsManager.kt +++ b/auth0/src/main/java/com/auth0/android/authentication/storage/SecureCredentialsManager.kt @@ -189,6 +189,8 @@ public class SecureCredentialsManager @VisibleForTesting(otherwise = VisibleForT ) storage.store(LEGACY_KEY_CACHE_EXPIRES_AT, credentials.expiresAt.time) storage.store(KEY_CAN_REFRESH, canRefresh) + storage.store(KEY_TOKEN_TYPE, credentials.type) + saveDPoPThumbprint(credentials) } catch (e: IncompatibleDeviceException) { throw CredentialsManagerException( CredentialsManagerException.Code.INCOMPATIBLE_DEVICE, e @@ -280,6 +282,12 @@ public class SecureCredentialsManager @VisibleForTesting(otherwise = VisibleForT return@execute } + val tokenType = storage.retrieveString(KEY_TOKEN_TYPE) ?: existingCredentials.type + validateDPoPState(tokenType)?.let { dpopError -> + callback.onFailure(dpopError) + return@execute + } + val request = authenticationClient.ssoExchange(existingCredentials.refreshToken) try { @@ -735,6 +743,8 @@ public class SecureCredentialsManager @VisibleForTesting(otherwise = VisibleForT storage.remove(KEY_EXPIRES_AT) storage.remove(LEGACY_KEY_CACHE_EXPIRES_AT) storage.remove(KEY_CAN_REFRESH) + storage.remove(KEY_TOKEN_TYPE) + storage.remove(KEY_DPOP_THUMBPRINT) clearBiometricSession() Log.d(TAG, "Credentials were just removed from the storage") } @@ -844,6 +854,11 @@ public class SecureCredentialsManager @VisibleForTesting(otherwise = VisibleForT callback.onFailure(CredentialsManagerException.NO_REFRESH_TOKEN) return@execute } + val tokenType = storage.retrieveString(KEY_TOKEN_TYPE) ?: credentials.type + validateDPoPState(tokenType)?.let { dpopError -> + callback.onFailure(dpopError) + return@execute + } Log.d(TAG, "Credentials have expired. Renewing them now...") val request = authenticationClient.renewAuth( credentials.refreshToken @@ -893,7 +908,8 @@ public class SecureCredentialsManager @VisibleForTesting(otherwise = VisibleForT callback.onFailure( CredentialsManagerException( CredentialsManagerException.Code.MFA_REQUIRED, - error.message ?: "Multi-factor authentication is required to complete the credential renewal.", + error.message + ?: "Multi-factor authentication is required to complete the credential renewal.", error, error.mfaRequiredErrorPayload ) @@ -958,6 +974,7 @@ public class SecureCredentialsManager @VisibleForTesting(otherwise = VisibleForT val encryptedEncodedJson = storage.retrieveString(getAPICredentialsKey(audience, scope)) //Check if existing api credentials are present and valid + var apiCredentialType: String? = null encryptedEncodedJson?.let { encryptedEncoded -> val encrypted = Base64.decode(encryptedEncoded, Base64.DEFAULT) val json: String = try { @@ -982,6 +999,7 @@ public class SecureCredentialsManager @VisibleForTesting(otherwise = VisibleForT } val apiCredentials = gson.fromJson(json, APICredentials::class.java) + apiCredentialType = apiCredentials.type val expiresAt = apiCredentials.expiresAt.time val willAccessTokenExpire = willExpire(expiresAt, minTtl.toLong()) @@ -1009,6 +1027,12 @@ public class SecureCredentialsManager @VisibleForTesting(otherwise = VisibleForT return@execute } + val tokenType = apiCredentialType ?: storage.retrieveString(KEY_TOKEN_TYPE) ?: existingCredentials.type + validateDPoPState(tokenType)?.let { dpopError -> + callback.onFailure(dpopError) + return@execute + } + val request = authenticationClient.renewAuth(refreshToken, audience, scope) request.addParameters(parameters) for (header in headers) { @@ -1051,7 +1075,8 @@ public class SecureCredentialsManager @VisibleForTesting(otherwise = VisibleForT callback.onFailure( CredentialsManagerException( CredentialsManagerException.Code.MFA_REQUIRED, - error.message ?: "Multi-factor authentication is required to complete the credential renewal.", + error.message + ?: "Multi-factor authentication is required to complete the credential renewal.", error, error.mfaRequiredErrorPayload ) @@ -1251,6 +1276,7 @@ public class SecureCredentialsManager @VisibleForTesting(otherwise = VisibleForT @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) internal const val KEY_ALIAS = "com.auth0.key" + // Using NO_SESSION to represent "no session" (uninitialized state) private const val NO_SESSION = -1L } diff --git a/auth0/src/main/java/com/auth0/android/dpop/DPoP.kt b/auth0/src/main/java/com/auth0/android/dpop/DPoP.kt index d84f7f610..b0490575e 100644 --- a/auth0/src/main/java/com/auth0/android/dpop/DPoP.kt +++ b/auth0/src/main/java/com/auth0/android/dpop/DPoP.kt @@ -198,6 +198,29 @@ public class DPoP(context: Context) { return HeaderData(token, proof) } + /** + * Returns whether a DPoP key pair currently exists in the Android KeyStore. + * + * This can be used to check if DPoP credentials are still available after events + * like device backup/restore or factory reset, which do not preserve KeyStore entries. + * + * ```kotlin + * + * if (!DPoP.hasKeyPair()) { + * // Key was lost — clear stored credentials and re-authenticate + * } + * + * ``` + * + * @return true if a DPoP key pair exists in the KeyStore, false otherwise. + * @throws DPoPException if there is an error accessing the KeyStore. + */ + @Throws(DPoPException::class) + @JvmStatic + public fun hasKeyPair(): Boolean { + return DPoPUtil.hasKeyPair() + } + /** * Method to clear the DPoP key pair from the keystore. It must be called when the user logs out * to prevent reuse of the key pair in subsequent sessions. diff --git a/auth0/src/test/java/com/auth0/android/authentication/storage/CredentialsManagerTest.kt b/auth0/src/test/java/com/auth0/android/authentication/storage/CredentialsManagerTest.kt index 4eee35709..42668a9ad 100644 --- a/auth0/src/test/java/com/auth0/android/authentication/storage/CredentialsManagerTest.kt +++ b/auth0/src/test/java/com/auth0/android/authentication/storage/CredentialsManagerTest.kt @@ -4,6 +4,10 @@ import com.auth0.android.NetworkErrorException import com.auth0.android.authentication.AuthenticationAPIClient import com.auth0.android.authentication.AuthenticationException import com.auth0.android.callback.Callback +import com.auth0.android.dpop.DPoPKeyStore +import com.auth0.android.dpop.DPoPUtil +import com.auth0.android.dpop.FakeECPrivateKey +import com.auth0.android.dpop.FakeECPublicKey import com.auth0.android.request.Request import com.auth0.android.request.internal.GsonProvider import com.auth0.android.request.internal.Jwt @@ -26,6 +30,7 @@ import com.nhaarman.mockitokotlin2.times import com.nhaarman.mockitokotlin2.verify import com.nhaarman.mockitokotlin2.verifyNoMoreInteractions import com.nhaarman.mockitokotlin2.verifyZeroInteractions +import com.nhaarman.mockitokotlin2.whenever import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.runBlocking import kotlinx.coroutines.test.runTest @@ -33,6 +38,7 @@ import org.hamcrest.MatcherAssert import org.hamcrest.Matchers import org.hamcrest.core.Is import org.hamcrest.core.IsInstanceOf +import org.junit.After import org.junit.Assert import org.junit.Assert.assertThrows import org.junit.Before @@ -74,6 +80,10 @@ public class CredentialsManagerTest { @Mock private lateinit var jwtDecoder: JWTDecoder + private lateinit var mockDPoPKeyStore: DPoPKeyStore + private val fakePublicKey = FakeECPublicKey() + private val fakePrivateKey = FakeECPrivateKey() + private val serialExecutor = Executor { runnable -> runnable.run() } private val credentialsCaptor: KArgumentCaptor = argumentCaptor() @@ -92,6 +102,10 @@ public class CredentialsManagerTest { @Before public fun setUp() { MockitoAnnotations.openMocks(this) + mockDPoPKeyStore = mock() + DPoPUtil.keyStore = mockDPoPKeyStore + whenever(mockDPoPKeyStore.hasKeyPair()).thenReturn(false) + val credentialsManager = CredentialsManager(client, storage, jwtDecoder, serialExecutor) manager = Mockito.spy(credentialsManager) //Needed to test expiration verification @@ -135,6 +149,7 @@ public class CredentialsManagerTest { verify(storage).store("com.auth0.expires_at", expirationTime) verify(storage).store("com.auth0.scope", "scope") verify(storage).store("com.auth0.cache_expires_at", expirationTime) + verify(storage).remove("com.auth0.dpop_key_thumbprint") verifyNoMoreInteractions(storage) } @@ -158,6 +173,7 @@ public class CredentialsManagerTest { verify(storage).store("com.auth0.expires_at", accessTokenExpirationTime) verify(storage).store("com.auth0.scope", "scope") verify(storage).store("com.auth0.cache_expires_at", accessTokenExpirationTime) + verify(storage).remove("com.auth0.dpop_key_thumbprint") verifyNoMoreInteractions(storage) } @@ -182,6 +198,7 @@ public class CredentialsManagerTest { verify(storage).store("com.auth0.expires_at", accessTokenExpirationTime) verify(storage).store("com.auth0.scope", "scope") verify(storage).store("com.auth0.cache_expires_at", accessTokenExpirationTime) + verify(storage).remove("com.auth0.dpop_key_thumbprint") verifyNoMoreInteractions(storage) } @@ -206,6 +223,7 @@ public class CredentialsManagerTest { verify(storage).store("com.auth0.expires_at", expirationTime) verify(storage).store("com.auth0.scope", "scope") verify(storage).store("com.auth0.cache_expires_at", expirationTime) + verify(storage).remove("com.auth0.dpop_key_thumbprint") verifyNoMoreInteractions(storage) } @@ -433,7 +451,8 @@ public class CredentialsManagerTest { "token", "type", Date(accessTokenExpiry), "scope" ) - Mockito.`when`(storage.retrieveString("audience::scope")).thenReturn(gson.toJson(apiCredentials)) + Mockito.`when`(storage.retrieveString("audience::scope")) + .thenReturn(gson.toJson(apiCredentials)) manager.getApiCredentials("audience", "scope", callback = apiCredentialsCallback) verify(apiCredentialsCallback).onSuccess(apiCredentialsCaptor.capture()) val retrievedCredentials = apiCredentialsCaptor.firstValue @@ -482,7 +501,10 @@ public class CredentialsManagerTest { verify(storage).store("com.auth0.id_token", renewedCredentials.idToken) // RefreshToken should not be replaced verify(storage).store("com.auth0.refresh_token", "refreshToken") - verify(storage).store("audience::newScope", gson.toJson(renewedCredentials.toAPICredentials())) + verify(storage).store( + "audience::newScope", + gson.toJson(renewedCredentials.toAPICredentials()) + ) // Verify the returned credentials are the latest val newAPiCredentials = apiCredentialsCaptor.firstValue MatcherAssert.assertThat(newAPiCredentials, Is.`is`(Matchers.notNullValue())) @@ -500,7 +522,8 @@ public class CredentialsManagerTest { "token", "type", Date(accessTokenExpiry), "scope" ) - Mockito.`when`(storage.retrieveString("audience::scope")).thenReturn(gson.toJson(apiCredentials)) + Mockito.`when`(storage.retrieveString("audience::scope")) + .thenReturn(gson.toJson(apiCredentials)) Mockito.`when`(storage.retrieveString("com.auth0.refresh_token")).thenReturn("refreshToken") Mockito.`when`( client.renewAuth("refreshToken", "audience", "scope") @@ -542,7 +565,8 @@ public class CredentialsManagerTest { "token", "type", Date(accessTokenExpiry), "scope" ) - Mockito.`when`(storage.retrieveString("audience::scope")).thenReturn(gson.toJson(apiCredentials)) + Mockito.`when`(storage.retrieveString("audience::scope")) + .thenReturn(gson.toJson(apiCredentials)) Mockito.`when`(storage.retrieveString("com.auth0.refresh_token")).thenReturn("refreshToken") Mockito.`when`( client.renewAuth("refreshToken", "audience", "scope") @@ -557,7 +581,12 @@ public class CredentialsManagerTest { val renewedCredentials = Credentials("newId", "newAccess", "newType", newRefresh, newDate, "scope") Mockito.`when`(request.execute()).thenReturn(renewedCredentials) - manager.getApiCredentials("audience", "scope", minTtl = 10, callback = apiCredentialsCallback) + manager.getApiCredentials( + "audience", + "scope", + minTtl = 10, + callback = apiCredentialsCallback + ) verify(apiCredentialsCallback).onSuccess( apiCredentialsCaptor.capture() ) @@ -582,7 +611,7 @@ public class CredentialsManagerTest { Mockito.`when`(storage.retrieveString("audience")).thenReturn(null) Mockito.`when`(storage.retrieveString("com.auth0.refresh_token")).thenReturn("refreshToken") Mockito.`when`( - client.renewAuth("refreshToken", "audience","newScope") + client.renewAuth("refreshToken", "audience", "newScope") ).thenReturn(request) val newDate = Date(CredentialsMock.ONE_HOUR_AHEAD_MS + ONE_HOUR_SECONDS * 1000) val jwtMock = mock() @@ -602,7 +631,10 @@ public class CredentialsManagerTest { verify(storage).store("com.auth0.id_token", renewedCredentials.idToken) // RefreshToken should be replaced verify(storage).store("com.auth0.refresh_token", "newRefreshToken") - verify(storage).store("audience::newScope", gson.toJson(renewedCredentials.toAPICredentials())) + verify(storage).store( + "audience::newScope", + gson.toJson(renewedCredentials.toAPICredentials()) + ) // Verify the returned credentials are the latest val newAPiCredentials = apiCredentialsCaptor.firstValue MatcherAssert.assertThat(newAPiCredentials, Is.`is`(Matchers.notNullValue())) @@ -630,7 +662,12 @@ public class CredentialsManagerTest { val renewedCredentials = Credentials("newId", "newAccess", "newType", newRefresh, newDate, "newScope") Mockito.`when`(request.execute()).thenReturn(renewedCredentials) - manager.getApiCredentials("audience", "newScope", minTtl = 1, callback = apiCredentialsCallback) + manager.getApiCredentials( + "audience", + "newScope", + minTtl = 1, + callback = apiCredentialsCallback + ) verify(apiCredentialsCallback).onFailure( exceptionCaptor.capture() ) @@ -651,7 +688,8 @@ public class CredentialsManagerTest { "token", "type", Date(accessTokenExpiry), "scope" ) - Mockito.`when`(storage.retrieveString("audience::scope")).thenReturn(gson.toJson(apiCredentials)) + Mockito.`when`(storage.retrieveString("audience::scope")) + .thenReturn(gson.toJson(apiCredentials)) val retrievedCredentials = manager.awaitApiCredentials("audience", "scope") MatcherAssert.assertThat(retrievedCredentials, Is.`is`(Matchers.notNullValue())) Assert.assertEquals(retrievedCredentials.accessToken, apiCredentials.accessToken) @@ -673,7 +711,7 @@ public class CredentialsManagerTest { Mockito.`when`(jwtMock.expiresAt).thenReturn(newDate) Mockito.`when`(jwtDecoder.decode("newId")).thenReturn(jwtMock) val renewedCredentials = - Credentials("newId", "newAccess", "newType",null, newDate, "newScope") + Credentials("newId", "newAccess", "newType", null, newDate, "newScope") Mockito.`when`(request.execute()).thenReturn(renewedCredentials) val retrievedCredentials = manager.awaitApiCredentials("audience") MatcherAssert.assertThat(retrievedCredentials, Is.`is`(Matchers.notNullValue())) @@ -912,7 +950,7 @@ public class CredentialsManagerTest { verify(storage).store( "com.auth0.cache_expires_at", renewedCredentials.expiresAt.time ) - verify(storage, never()).remove(ArgumentMatchers.anyString()) + verify(storage).remove("com.auth0.dpop_key_thumbprint") // Verify the returned credentials are the latest val retrievedCredentials = credentialsCaptor.firstValue @@ -970,7 +1008,7 @@ public class CredentialsManagerTest { verify(storage).store( "com.auth0.cache_expires_at", renewedCredentials.expiresAt.time ) - verify(storage, never()).remove(ArgumentMatchers.anyString()) + verify(storage).remove("com.auth0.dpop_key_thumbprint") // Verify the returned credentials are the latest val retrievedCredentials = credentialsCaptor.firstValue @@ -1027,7 +1065,7 @@ public class CredentialsManagerTest { verify(storage).store( "com.auth0.cache_expires_at", renewedCredentials.expiresAt.time ) - verify(storage, never()).remove(ArgumentMatchers.anyString()) + verify(storage).remove("com.auth0.dpop_key_thumbprint") // Verify the returned credentials are the latest val retrievedCredentials = credentialsCaptor.firstValue @@ -1086,7 +1124,7 @@ public class CredentialsManagerTest { verify(storage).store( "com.auth0.cache_expires_at", renewedCredentials.expiresAt.time ) - verify(storage, never()).remove(ArgumentMatchers.anyString()) + verify(storage).remove("com.auth0.dpop_key_thumbprint") // Verify the returned credentials are the latest val retrievedCredentials = credentialsCaptor.firstValue @@ -1144,7 +1182,7 @@ public class CredentialsManagerTest { verify(storage).store( "com.auth0.cache_expires_at", renewedCredentials.expiresAt.time ) - verify(storage, never()).remove(ArgumentMatchers.anyString()) + verify(storage).remove("com.auth0.dpop_key_thumbprint") //// Verify the returned credentials are the latest val retrievedCredentials = credentialsCaptor.firstValue @@ -1252,7 +1290,7 @@ public class CredentialsManagerTest { verify(storage).store( "com.auth0.cache_expires_at", renewedCredentials.expiresAt.time ) - verify(storage, never()).remove(ArgumentMatchers.anyString()) + verify(storage).remove("com.auth0.dpop_key_thumbprint") //// Verify the returned credentials are the latest val retrievedCredentials = credentialsCaptor.firstValue @@ -1482,6 +1520,7 @@ public class CredentialsManagerTest { verify(storage).remove("com.auth0.expires_at") verify(storage).remove("com.auth0.scope") verify(storage).remove("com.auth0.cache_expires_at") + verify(storage).remove("com.auth0.dpop_key_thumbprint") verifyNoMoreInteractions(storage) } @@ -1959,7 +1998,8 @@ public class CredentialsManagerTest { Mockito.`when`(storage.retrieveString("com.auth0.token_type")).thenReturn("type") val expirationTime = CredentialsMock.CURRENT_TIME_MS // Expired Mockito.`when`(storage.retrieveLong("com.auth0.expires_at")).thenReturn(expirationTime) - Mockito.`when`(storage.retrieveLong("com.auth0.cache_expires_at")).thenReturn(expirationTime) + Mockito.`when`(storage.retrieveLong("com.auth0.cache_expires_at")) + .thenReturn(expirationTime) Mockito.`when`(storage.retrieveString("com.auth0.scope")).thenReturn("scope") Mockito.`when`(client.renewAuth("refreshToken")).thenReturn(request) @@ -1975,10 +2015,10 @@ public class CredentialsManagerTest { ) ) val mfaRequiredException = AuthenticationException(mfaRequiredValues, 403) - + MatcherAssert.assertThat(mfaRequiredException.isMultifactorRequired, Is.`is`(true)) MatcherAssert.assertThat(mfaRequiredException.getCode(), Is.`is`("mfa_required")) - + Mockito.`when`(request.execute()).thenThrow(mfaRequiredException) manager.getCredentials(callback) @@ -1988,11 +2028,20 @@ public class CredentialsManagerTest { MatcherAssert.assertThat(exception, Is.`is`(Matchers.notNullValue())) MatcherAssert.assertThat(exception.message, Matchers.containsString("authenticate")) MatcherAssert.assertThat(exception.cause, Is.`is`(mfaRequiredException)) - - MatcherAssert.assertThat(exception.mfaRequiredErrorPayload, Is.`is`(Matchers.notNullValue())) + + MatcherAssert.assertThat( + exception.mfaRequiredErrorPayload, + Is.`is`(Matchers.notNullValue()) + ) MatcherAssert.assertThat(exception.mfaToken, Is.`is`("test-mfa-token-12345")) - MatcherAssert.assertThat(exception.mfaRequiredErrorPayload?.mfaRequirements?.challenge, Is.`is`(Matchers.notNullValue())) - MatcherAssert.assertThat(exception.mfaRequiredErrorPayload?.mfaRequirements?.challenge?.size, Is.`is`(2)) + MatcherAssert.assertThat( + exception.mfaRequiredErrorPayload?.mfaRequirements?.challenge, + Is.`is`(Matchers.notNullValue()) + ) + MatcherAssert.assertThat( + exception.mfaRequiredErrorPayload?.mfaRequirements?.challenge?.size, + Is.`is`(2) + ) } @Test @@ -2003,7 +2052,8 @@ public class CredentialsManagerTest { Mockito.`when`(storage.retrieveString("com.auth0.token_type")).thenReturn("type") val expirationTime = CredentialsMock.CURRENT_TIME_MS // Expired Mockito.`when`(storage.retrieveLong("com.auth0.expires_at")).thenReturn(expirationTime) - Mockito.`when`(storage.retrieveLong("com.auth0.cache_expires_at")).thenReturn(expirationTime) + Mockito.`when`(storage.retrieveLong("com.auth0.cache_expires_at")) + .thenReturn(expirationTime) Mockito.`when`(storage.retrieveString("com.auth0.scope")).thenReturn("scope") Mockito.`when`(client.renewAuth("refreshToken")).thenReturn(request) @@ -2028,9 +2078,18 @@ public class CredentialsManagerTest { val exception = exceptionCaptor.firstValue MatcherAssert.assertThat(exception.message, Matchers.containsString("authenticate")) MatcherAssert.assertThat(exception.mfaToken, Is.`is`("enroll-mfa-token")) - MatcherAssert.assertThat(exception.mfaRequiredErrorPayload?.mfaRequirements?.enroll, Is.`is`(Matchers.notNullValue())) - MatcherAssert.assertThat(exception.mfaRequiredErrorPayload?.mfaRequirements?.enroll?.size, Is.`is`(3)) - MatcherAssert.assertThat(exception.mfaRequiredErrorPayload?.mfaRequirements?.challenge, Is.`is`(Matchers.nullValue())) + MatcherAssert.assertThat( + exception.mfaRequiredErrorPayload?.mfaRequirements?.enroll, + Is.`is`(Matchers.notNullValue()) + ) + MatcherAssert.assertThat( + exception.mfaRequiredErrorPayload?.mfaRequirements?.enroll?.size, + Is.`is`(3) + ) + MatcherAssert.assertThat( + exception.mfaRequiredErrorPayload?.mfaRequirements?.challenge, + Is.`is`(Matchers.nullValue()) + ) } @Test @@ -2041,7 +2100,8 @@ public class CredentialsManagerTest { Mockito.`when`(storage.retrieveString("com.auth0.token_type")).thenReturn("type") val expirationTime = CredentialsMock.CURRENT_TIME_MS // Expired Mockito.`when`(storage.retrieveLong("com.auth0.expires_at")).thenReturn(expirationTime) - Mockito.`when`(storage.retrieveLong("com.auth0.cache_expires_at")).thenReturn(expirationTime) + Mockito.`when`(storage.retrieveLong("com.auth0.cache_expires_at")) + .thenReturn(expirationTime) Mockito.`when`(storage.retrieveString("com.auth0.scope")).thenReturn("scope") Mockito.`when`(client.renewAuth("refreshToken")).thenReturn(request) @@ -2059,7 +2119,10 @@ public class CredentialsManagerTest { verify(callback).onFailure(exceptionCaptor.capture()) val exception = exceptionCaptor.firstValue - MatcherAssert.assertThat(exception.message, Matchers.containsString("processing the request")) + MatcherAssert.assertThat( + exception.message, + Matchers.containsString("processing the request") + ) MatcherAssert.assertThat(exception.mfaRequiredErrorPayload, Is.`is`(Matchers.nullValue())) MatcherAssert.assertThat(exception.mfaToken, Is.`is`(Matchers.nullValue())) } @@ -2073,7 +2136,8 @@ public class CredentialsManagerTest { Mockito.`when`(storage.retrieveString("com.auth0.token_type")).thenReturn("type") val expirationTime = CredentialsMock.CURRENT_TIME_MS // Expired Mockito.`when`(storage.retrieveLong("com.auth0.expires_at")).thenReturn(expirationTime) - Mockito.`when`(storage.retrieveLong("com.auth0.cache_expires_at")).thenReturn(expirationTime) + Mockito.`when`(storage.retrieveLong("com.auth0.cache_expires_at")) + .thenReturn(expirationTime) Mockito.`when`(storage.retrieveString("com.auth0.scope")).thenReturn("scope") Mockito.`when`(client.renewAuth("refreshToken")).thenReturn(request) @@ -2095,7 +2159,10 @@ public class CredentialsManagerTest { } MatcherAssert.assertThat(exception, Is.`is`(Matchers.notNullValue())) MatcherAssert.assertThat(exception.cause, Is.`is`(mfaRequiredException)) - MatcherAssert.assertThat(exception.mfaRequiredErrorPayload, Is.`is`(Matchers.notNullValue())) + MatcherAssert.assertThat( + exception.mfaRequiredErrorPayload, + Is.`is`(Matchers.notNullValue()) + ) MatcherAssert.assertThat(exception.mfaToken, Is.`is`("await-mfa-token-12345")) } @@ -2107,7 +2174,8 @@ public class CredentialsManagerTest { Mockito.`when`(storage.retrieveString("com.auth0.token_type")).thenReturn("type") val expirationTime = CredentialsMock.CURRENT_TIME_MS // Expired Mockito.`when`(storage.retrieveLong("com.auth0.expires_at")).thenReturn(expirationTime) - Mockito.`when`(storage.retrieveLong("com.auth0.cache_expires_at")).thenReturn(expirationTime) + Mockito.`when`(storage.retrieveLong("com.auth0.cache_expires_at")) + .thenReturn(expirationTime) Mockito.`when`(storage.retrieveString("com.auth0.scope")).thenReturn("scope") Mockito.`when`(client.renewAuth("refreshToken")).thenReturn(request) @@ -2123,14 +2191,263 @@ public class CredentialsManagerTest { verify(callback).onFailure(exceptionCaptor.capture()) val exception = exceptionCaptor.firstValue - + MatcherAssert.assertThat(exception.cause, Is.`is`(Matchers.notNullValue())) - MatcherAssert.assertThat(exception.cause, IsInstanceOf.instanceOf(AuthenticationException::class.java)) - + MatcherAssert.assertThat( + exception.cause, + IsInstanceOf.instanceOf(AuthenticationException::class.java) + ) + val causeException = exception.cause as AuthenticationException MatcherAssert.assertThat(causeException.getCode(), Is.`is`("mfa_required")) MatcherAssert.assertThat(causeException.isMultifactorRequired, Is.`is`(true)) - MatcherAssert.assertThat(causeException.getDescription(), Is.`is`("MFA is required for this action")) + MatcherAssert.assertThat( + causeException.getDescription(), + Is.`is`("MFA is required for this action") + ) + } + + @Test + public fun shouldStoreDPoPThumbprintWhenCredentialsTypeIsDPoP() { + val expirationTime = CredentialsMock.ONE_HOUR_AHEAD_MS + val credentials = CredentialsMock.create( + "idToken", "accessToken", "DPoP", "refreshToken", Date(expirationTime), "scope" + ) + prepareJwtDecoderMock(Date(expirationTime)) + whenever(mockDPoPKeyStore.hasKeyPair()).thenReturn(true) + whenever(mockDPoPKeyStore.getKeyPair()).thenReturn(Pair(fakePrivateKey, fakePublicKey)) + + manager.saveCredentials(credentials) + + verify(storage).store(eq("com.auth0.dpop_key_thumbprint"), ArgumentMatchers.anyString()) + verify(storage, never()).remove("com.auth0.dpop_key_thumbprint") + } + + @Test + public fun shouldStoreDPoPThumbprintWhenIsDPoPEnabledIsTrue() { + val expirationTime = CredentialsMock.ONE_HOUR_AHEAD_MS + val credentials = CredentialsMock.create( + "idToken", "accessToken", "Bearer", "refreshToken", Date(expirationTime), "scope" + ) + prepareJwtDecoderMock(Date(expirationTime)) + Mockito.doReturn(true).`when`(client).isDPoPEnabled + whenever(mockDPoPKeyStore.hasKeyPair()).thenReturn(true) + whenever(mockDPoPKeyStore.getKeyPair()).thenReturn(Pair(fakePrivateKey, fakePublicKey)) + + manager.saveCredentials(credentials) + + verify(storage).store(eq("com.auth0.dpop_key_thumbprint"), ArgumentMatchers.anyString()) + verify(storage, never()).remove("com.auth0.dpop_key_thumbprint") + } + + @Test + public fun shouldRemoveDPoPThumbprintForBearerCredentialsWithoutDPoP() { + val expirationTime = CredentialsMock.ONE_HOUR_AHEAD_MS + val credentials = CredentialsMock.create( + "idToken", "accessToken", "Bearer", "refreshToken", Date(expirationTime), "scope" + ) + prepareJwtDecoderMock(Date(expirationTime)) + + manager.saveCredentials(credentials) + + verify(storage).remove("com.auth0.dpop_key_thumbprint") + verify(storage, never()).store( + eq("com.auth0.dpop_key_thumbprint"), + ArgumentMatchers.anyString() + ) + } + + @Test + public fun shouldRemoveDPoPThumbprintWhenNoKeyPairExists() { + val expirationTime = CredentialsMock.ONE_HOUR_AHEAD_MS + val credentials = CredentialsMock.create( + "idToken", "accessToken", "DPoP", "refreshToken", Date(expirationTime), "scope" + ) + prepareJwtDecoderMock(Date(expirationTime)) + whenever(mockDPoPKeyStore.hasKeyPair()).thenReturn(false) + + manager.saveCredentials(credentials) + + verify(storage).remove("com.auth0.dpop_key_thumbprint") + verify(storage, never()).store( + eq("com.auth0.dpop_key_thumbprint"), + ArgumentMatchers.anyString() + ) + } + + @Test + public fun shouldFailOnGetCredentialsWithDPoPKeyMissingWhenKeyNotInKeyStore() { + Mockito.`when`(storage.retrieveString("com.auth0.id_token")).thenReturn("idToken") + Mockito.`when`(storage.retrieveString("com.auth0.access_token")).thenReturn("accessToken") + Mockito.`when`(storage.retrieveString("com.auth0.refresh_token")).thenReturn("refreshToken") + Mockito.`when`(storage.retrieveString("com.auth0.token_type")).thenReturn("DPoP") + val expirationTime = CredentialsMock.CURRENT_TIME_MS // expired + Mockito.`when`(storage.retrieveLong("com.auth0.expires_at")).thenReturn(expirationTime) + Mockito.`when`(storage.retrieveString("com.auth0.scope")).thenReturn("scope") + whenever(mockDPoPKeyStore.hasKeyPair()).thenReturn(false) + + manager.getCredentials(callback) + + verify(callback).onFailure(exceptionCaptor.capture()) + val exception = exceptionCaptor.firstValue + MatcherAssert.assertThat(exception, Is.`is`(CredentialsManagerException.DPOP_KEY_MISSING)) + } + + @Test + public fun shouldFailOnGetCredentialsWithDPoPNotConfiguredWhenClientNotSetup() { + Mockito.`when`(storage.retrieveString("com.auth0.id_token")).thenReturn("idToken") + Mockito.`when`(storage.retrieveString("com.auth0.access_token")).thenReturn("accessToken") + Mockito.`when`(storage.retrieveString("com.auth0.refresh_token")).thenReturn("refreshToken") + Mockito.`when`(storage.retrieveString("com.auth0.token_type")).thenReturn("DPoP") + val expirationTime = CredentialsMock.CURRENT_TIME_MS // expired + Mockito.`when`(storage.retrieveLong("com.auth0.expires_at")).thenReturn(expirationTime) + Mockito.`when`(storage.retrieveString("com.auth0.scope")).thenReturn("scope") + whenever(mockDPoPKeyStore.hasKeyPair()).thenReturn(true) + + manager.getCredentials(callback) + + verify(callback).onFailure(exceptionCaptor.capture()) + val exception = exceptionCaptor.firstValue + MatcherAssert.assertThat( + exception, + Is.`is`(CredentialsManagerException.DPOP_NOT_CONFIGURED) + ) + } + + @Test + public fun shouldFailOnGetCredentialsWithDPoPKeyMismatchWhenThumbprintsDontMatch() { + Mockito.`when`(storage.retrieveString("com.auth0.id_token")).thenReturn("idToken") + Mockito.`when`(storage.retrieveString("com.auth0.access_token")).thenReturn("accessToken") + Mockito.`when`(storage.retrieveString("com.auth0.refresh_token")).thenReturn("refreshToken") + Mockito.`when`(storage.retrieveString("com.auth0.token_type")).thenReturn("DPoP") + val expirationTime = CredentialsMock.CURRENT_TIME_MS // expired + Mockito.`when`(storage.retrieveLong("com.auth0.expires_at")).thenReturn(expirationTime) + Mockito.`when`(storage.retrieveString("com.auth0.scope")).thenReturn("scope") + Mockito.`when`(storage.retrieveString("com.auth0.dpop_key_thumbprint")) + .thenReturn("old-thumbprint-from-previous-key") + whenever(mockDPoPKeyStore.hasKeyPair()).thenReturn(true) + whenever(mockDPoPKeyStore.getKeyPair()).thenReturn(Pair(fakePrivateKey, fakePublicKey)) + Mockito.doReturn(true).`when`(client).isDPoPEnabled + + manager.getCredentials(callback) + + verify(callback).onFailure(exceptionCaptor.capture()) + val exception = exceptionCaptor.firstValue + MatcherAssert.assertThat(exception, Is.`is`(CredentialsManagerException.DPOP_KEY_MISMATCH)) + } + + @Test + public fun shouldBackfillDPoPThumbprintForMigrationScenario() { + Mockito.`when`(storage.retrieveString("com.auth0.id_token")).thenReturn("idToken") + Mockito.`when`(storage.retrieveString("com.auth0.access_token")).thenReturn("accessToken") + Mockito.`when`(storage.retrieveString("com.auth0.refresh_token")).thenReturn("refreshToken") + Mockito.`when`(storage.retrieveString("com.auth0.token_type")).thenReturn("DPoP") + val expirationTime = CredentialsMock.CURRENT_TIME_MS // expired + Mockito.`when`(storage.retrieveLong("com.auth0.expires_at")).thenReturn(expirationTime) + Mockito.`when`(storage.retrieveString("com.auth0.scope")).thenReturn("scope") + // No stored thumbprint (migration scenario) + Mockito.`when`(storage.retrieveString("com.auth0.dpop_key_thumbprint")).thenReturn(null) + whenever(mockDPoPKeyStore.hasKeyPair()).thenReturn(true) + whenever(mockDPoPKeyStore.getKeyPair()).thenReturn(Pair(fakePrivateKey, fakePublicKey)) + Mockito.doReturn(true).`when`(client).isDPoPEnabled + Mockito.`when`(client.renewAuth("refreshToken")).thenReturn(request) + val newDate = Date(CredentialsMock.ONE_HOUR_AHEAD_MS + ONE_HOUR_SECONDS * 1000) + val jwtMock = mock() + Mockito.`when`(jwtMock.expiresAt).thenReturn(newDate) + Mockito.`when`(jwtDecoder.decode("newId")).thenReturn(jwtMock) + val renewedCredentials = + Credentials("newId", "newAccess", "DPoP", "newRefresh", newDate, "scope") + Mockito.`when`(request.execute()).thenReturn(renewedCredentials) + + manager.getCredentials(callback) + + // Verify thumbprint was backfilled during validation (and also stored again during saveCredentials after renewal) + verify(storage, Mockito.atLeastOnce()).store( + eq("com.auth0.dpop_key_thumbprint"), + ArgumentMatchers.anyString() + ) + verify(callback).onSuccess(credentialsCaptor.capture()) + } + + @Test + public fun shouldTriggerDPoPValidationViaStoredThumbprintEvenForBearerTokenType() { + Mockito.`when`(storage.retrieveString("com.auth0.id_token")).thenReturn("idToken") + Mockito.`when`(storage.retrieveString("com.auth0.access_token")).thenReturn("accessToken") + Mockito.`when`(storage.retrieveString("com.auth0.refresh_token")).thenReturn("refreshToken") + Mockito.`when`(storage.retrieveString("com.auth0.token_type")).thenReturn("Bearer") + val expirationTime = CredentialsMock.CURRENT_TIME_MS // expired + Mockito.`when`(storage.retrieveLong("com.auth0.expires_at")).thenReturn(expirationTime) + Mockito.`when`(storage.retrieveString("com.auth0.scope")).thenReturn("scope") + Mockito.`when`(storage.retrieveString("com.auth0.dpop_key_thumbprint")) + .thenReturn("some-thumbprint") + whenever(mockDPoPKeyStore.hasKeyPair()).thenReturn(false) + + manager.getCredentials(callback) + + verify(callback).onFailure(exceptionCaptor.capture()) + val exception = exceptionCaptor.firstValue + MatcherAssert.assertThat(exception, Is.`is`(CredentialsManagerException.DPOP_KEY_MISSING)) + } + + @Test + public fun shouldFailOnGetSsoCredentialsWithDPoPKeyMissingWhenKeyNotInKeyStore() { + Mockito.`when`(storage.retrieveString("com.auth0.refresh_token")).thenReturn("refreshToken") + Mockito.`when`(storage.retrieveString("com.auth0.token_type")).thenReturn("DPoP") + whenever(mockDPoPKeyStore.hasKeyPair()).thenReturn(false) + + manager.getSsoCredentials(ssoCallback) + + verify(ssoCallback).onFailure(exceptionCaptor.capture()) + val exception = exceptionCaptor.firstValue + MatcherAssert.assertThat(exception, Is.`is`(CredentialsManagerException.DPOP_KEY_MISSING)) + } + + @Test + public fun shouldFailOnGetApiCredentialsWithDPoPKeyMissingWhenKeyNotInKeyStore() { + val accessTokenExpiry = CredentialsMock.CURRENT_TIME_MS // expired + val apiCredentials = ApiCredentialsMock.create( + accessToken = "apiToken", + type = "DPoP", + expiresAt = Date(accessTokenExpiry), + scope = "read:data" + ) + Mockito.`when`(storage.retrieveString("audience::read:data")) + .thenReturn(gson.toJson(apiCredentials)) + Mockito.`when`(storage.retrieveString("com.auth0.refresh_token")).thenReturn("refreshToken") + whenever(mockDPoPKeyStore.hasKeyPair()).thenReturn(false) + + manager.getApiCredentials("audience", "read:data", callback = apiCredentialsCallback) + + verify(apiCredentialsCallback).onFailure(exceptionCaptor.capture()) + val exception = exceptionCaptor.firstValue + MatcherAssert.assertThat(exception, Is.`is`(CredentialsManagerException.DPOP_KEY_MISSING)) + } + + @Test + public fun shouldUseApiCredentialTypeForDPoPValidationInsteadOfBaseTokenType() { + val accessTokenExpiry = CredentialsMock.CURRENT_TIME_MS // expired + val apiCredentials = ApiCredentialsMock.create( + accessToken = "apiToken", + type = "DPoP", + expiresAt = Date(accessTokenExpiry), + scope = "read:data" + ) + Mockito.`when`(storage.retrieveString("audience::read:data")) + .thenReturn(gson.toJson(apiCredentials)) + Mockito.`when`(storage.retrieveString("com.auth0.refresh_token")).thenReturn("refreshToken") + Mockito.`when`(storage.retrieveString("com.auth0.token_type")).thenReturn("Bearer") + whenever(mockDPoPKeyStore.hasKeyPair()).thenReturn(false) + + manager.getApiCredentials("audience", "read:data", callback = apiCredentialsCallback) + + verify(apiCredentialsCallback).onFailure(exceptionCaptor.capture()) + val exception = exceptionCaptor.firstValue + MatcherAssert.assertThat(exception, Is.`is`(CredentialsManagerException.DPOP_KEY_MISSING)) + } + + @After + public fun tearDown() { + DPoPUtil.keyStore = DPoPKeyStore() } private fun prepareJwtDecoderMock(expiresAt: Date?) { diff --git a/auth0/src/test/java/com/auth0/android/authentication/storage/SecureCredentialsManagerTest.kt b/auth0/src/test/java/com/auth0/android/authentication/storage/SecureCredentialsManagerTest.kt index 078690cc3..10d495030 100644 --- a/auth0/src/test/java/com/auth0/android/authentication/storage/SecureCredentialsManagerTest.kt +++ b/auth0/src/test/java/com/auth0/android/authentication/storage/SecureCredentialsManagerTest.kt @@ -10,6 +10,10 @@ import com.auth0.android.NetworkErrorException import com.auth0.android.authentication.AuthenticationAPIClient import com.auth0.android.authentication.AuthenticationException import com.auth0.android.callback.Callback +import com.auth0.android.dpop.DPoPKeyStore +import com.auth0.android.dpop.DPoPUtil +import com.auth0.android.dpop.FakeECPrivateKey +import com.auth0.android.dpop.FakeECPublicKey import com.auth0.android.request.Request import com.auth0.android.request.internal.GsonProvider import com.auth0.android.request.internal.Jwt @@ -31,6 +35,7 @@ import com.nhaarman.mockitokotlin2.never import com.nhaarman.mockitokotlin2.times import com.nhaarman.mockitokotlin2.verify import com.nhaarman.mockitokotlin2.verifyNoMoreInteractions +import com.nhaarman.mockitokotlin2.whenever import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.runBlocking import kotlinx.coroutines.test.runTest @@ -38,6 +43,7 @@ import org.hamcrest.MatcherAssert import org.hamcrest.Matchers import org.hamcrest.core.Is import org.hamcrest.core.IsInstanceOf +import org.junit.After import org.junit.Assert import org.junit.Assert.assertThrows import org.junit.Before @@ -103,6 +109,10 @@ public class SecureCredentialsManagerTest { private lateinit var fragmentActivity: FragmentActivity + private lateinit var mockDPoPKeyStore: DPoPKeyStore + private val fakePublicKey = FakeECPublicKey() + private val fakePrivateKey = FakeECPrivateKey() + private val serialExecutor = Executor { runnable -> runnable.run() } private val credentialsCaptor: KArgumentCaptor = argumentCaptor() @@ -122,6 +132,9 @@ public class SecureCredentialsManagerTest { @Before public fun setUp() { MockitoAnnotations.openMocks(this) + mockDPoPKeyStore = mock() + DPoPUtil.keyStore = mockDPoPKeyStore + whenever(mockDPoPKeyStore.hasKeyPair()).thenReturn(false) val activity = Robolectric.buildActivity(Activity::class.java).create().start().resume().get() val activityContext = Mockito.spy(activity) @@ -612,6 +625,8 @@ public class SecureCredentialsManagerTest { verify(storage) .store("com.auth0.credentials_access_token_expires_at", sharedExpirationTime) verify(storage).store("com.auth0.credentials_can_refresh", true) + verify(storage).store("com.auth0.token_type", "type") + verify(storage).remove("com.auth0.dpop_key_thumbprint") verifyNoMoreInteractions(storage) val encodedJson = stringCaptor.firstValue MatcherAssert.assertThat(encodedJson, Is.`is`(Matchers.notNullValue())) @@ -647,6 +662,8 @@ public class SecureCredentialsManagerTest { verify(storage) .store("com.auth0.credentials_access_token_expires_at", accessTokenExpirationTime) verify(storage).store("com.auth0.credentials_can_refresh", true) + verify(storage).store("com.auth0.token_type", "type") + verify(storage).remove("com.auth0.dpop_key_thumbprint") verifyNoMoreInteractions(storage) val encodedJson = stringCaptor.firstValue MatcherAssert.assertThat(encodedJson, Is.`is`(Matchers.notNullValue())) @@ -686,6 +703,8 @@ public class SecureCredentialsManagerTest { verify(storage) .store("com.auth0.credentials_access_token_expires_at", accessTokenExpirationTime) verify(storage).store("com.auth0.credentials_can_refresh", true) + verify(storage).store("com.auth0.token_type", "type") + verify(storage).remove("com.auth0.dpop_key_thumbprint") verifyNoMoreInteractions(storage) val encodedJson = stringCaptor.firstValue MatcherAssert.assertThat(encodedJson, Is.`is`(Matchers.notNullValue())) @@ -725,6 +744,8 @@ public class SecureCredentialsManagerTest { verify(storage) .store("com.auth0.credentials_access_token_expires_at", expirationTime) verify(storage).store("com.auth0.credentials_can_refresh", false) + verify(storage).store("com.auth0.token_type", "type") + verify(storage).remove("com.auth0.dpop_key_thumbprint") verifyNoMoreInteractions(storage) val encodedJson = stringCaptor.firstValue MatcherAssert.assertThat(encodedJson, Is.`is`(Matchers.notNullValue())) @@ -1245,7 +1266,7 @@ public class SecureCredentialsManagerTest { verify(storage).store("com.auth0.credentials_expires_at", newDate.time) verify(storage).store("com.auth0.credentials_access_token_expires_at", newDate.time) verify(storage).store("com.auth0.credentials_can_refresh", true) - verify(storage, never()).remove(anyString()) + verify(storage).remove("com.auth0.dpop_key_thumbprint") // Verify the returned credentials are the latest val retrievedCredentials = credentialsCaptor.firstValue @@ -1369,7 +1390,7 @@ public class SecureCredentialsManagerTest { verify(storage).store("com.auth0.credentials_expires_at", newDate.time) verify(storage).store("com.auth0.credentials_access_token_expires_at", newDate.time) verify(storage).store("com.auth0.credentials_can_refresh", true) - verify(storage, never()).remove(anyString()) + verify(storage).remove("com.auth0.dpop_key_thumbprint") // Verify the returned credentials are the latest val retrievedCredentials = credentialsCaptor.firstValue @@ -1437,7 +1458,7 @@ public class SecureCredentialsManagerTest { verify(storage).store("com.auth0.credentials_expires_at", newDate.time) verify(storage).store("com.auth0.credentials_access_token_expires_at", newDate.time) verify(storage).store("com.auth0.credentials_can_refresh", true) - verify(storage, never()).remove(anyString()) + verify(storage).remove("com.auth0.dpop_key_thumbprint") // Verify the returned credentials are the latest val retrievedCredentials = credentialsCaptor.firstValue @@ -1507,7 +1528,7 @@ public class SecureCredentialsManagerTest { verify(storage).store("com.auth0.credentials_expires_at", newDate.time) verify(storage).store("com.auth0.credentials_access_token_expires_at", newDate.time) verify(storage).store("com.auth0.credentials_can_refresh", true) - verify(storage, never()).remove(anyString()) + verify(storage).remove("com.auth0.dpop_key_thumbprint") // Verify the returned credentials are the latest val retrievedCredentials = credentialsCaptor.firstValue @@ -1592,7 +1613,7 @@ public class SecureCredentialsManagerTest { .store(eq("com.auth0.credentials"), stringCaptor.capture()) verify(storage).store("com.auth0.credentials_expires_at", newDate.time) verify(storage).store("com.auth0.credentials_can_refresh", true) - verify(storage, never()).remove(anyString()) + verify(storage).remove("com.auth0.dpop_key_thumbprint") // Verify the returned credentials are the latest val retrievedCredentials = credentialsCaptor.firstValue @@ -1656,7 +1677,7 @@ public class SecureCredentialsManagerTest { .store(eq("com.auth0.credentials"), stringCaptor.capture()) verify(storage).store("com.auth0.credentials_expires_at", newDate.time) verify(storage).store("com.auth0.credentials_can_refresh", true) - verify(storage, never()).remove(anyString()) + verify(storage).remove("com.auth0.dpop_key_thumbprint") // Verify the returned credentials are the latest val retrievedCredentials = credentialsCaptor.firstValue @@ -2153,6 +2174,8 @@ public class SecureCredentialsManagerTest { verify(storage).remove("com.auth0.credentials_expires_at") verify(storage).remove("com.auth0.credentials_access_token_expires_at") verify(storage).remove("com.auth0.credentials_can_refresh") + verify(storage).remove("com.auth0.token_type") + verify(storage).remove("com.auth0.dpop_key_thumbprint") verifyNoMoreInteractions(storage) } @@ -3598,6 +3621,247 @@ public class SecureCredentialsManagerTest { } + @Test + public fun shouldStoreDPoPThumbprintWhenCredentialsTypeIsDPoP() { + val expirationTime = CredentialsMock.ONE_HOUR_AHEAD_MS + val credentials = CredentialsMock.create( + "idToken", "accessToken", "DPoP", "refreshToken", Date(expirationTime), "scope" + ) + val json = gson.toJson(credentials) + prepareJwtDecoderMock(Date(expirationTime)) + Mockito.`when`(crypto.encrypt(json.toByteArray())).thenReturn(json.toByteArray()) + whenever(mockDPoPKeyStore.hasKeyPair()).thenReturn(true) + whenever(mockDPoPKeyStore.getKeyPair()).thenReturn(Pair(fakePrivateKey, fakePublicKey)) + + manager.saveCredentials(credentials) + + verify(storage).store(eq("com.auth0.dpop_key_thumbprint"), anyString()) + verify(storage, never()).remove("com.auth0.dpop_key_thumbprint") + } + + @Test + public fun shouldStoreDPoPThumbprintWhenIsDPoPEnabledIsTrue() { + val expirationTime = CredentialsMock.ONE_HOUR_AHEAD_MS + val credentials = CredentialsMock.create( + "idToken", "accessToken", "Bearer", "refreshToken", Date(expirationTime), "scope" + ) + val json = gson.toJson(credentials) + prepareJwtDecoderMock(Date(expirationTime)) + Mockito.`when`(crypto.encrypt(json.toByteArray())).thenReturn(json.toByteArray()) + Mockito.doReturn(true).`when`(client).isDPoPEnabled + whenever(mockDPoPKeyStore.hasKeyPair()).thenReturn(true) + whenever(mockDPoPKeyStore.getKeyPair()).thenReturn(Pair(fakePrivateKey, fakePublicKey)) + + manager.saveCredentials(credentials) + + verify(storage).store(eq("com.auth0.dpop_key_thumbprint"), anyString()) + verify(storage, never()).remove("com.auth0.dpop_key_thumbprint") + } + + @Test + public fun shouldRemoveDPoPThumbprintForBearerCredentialsWithoutDPoP() { + val expirationTime = CredentialsMock.ONE_HOUR_AHEAD_MS + val credentials = CredentialsMock.create( + "idToken", "accessToken", "Bearer", "refreshToken", Date(expirationTime), "scope" + ) + val json = gson.toJson(credentials) + prepareJwtDecoderMock(Date(expirationTime)) + Mockito.`when`(crypto.encrypt(json.toByteArray())).thenReturn(json.toByteArray()) + + manager.saveCredentials(credentials) + + verify(storage).remove("com.auth0.dpop_key_thumbprint") + verify(storage, never()).store(eq("com.auth0.dpop_key_thumbprint"), anyString()) + } + + @Test + public fun shouldRemoveDPoPThumbprintWhenNoKeyPairExists() { + val expirationTime = CredentialsMock.ONE_HOUR_AHEAD_MS + val credentials = CredentialsMock.create( + "idToken", "accessToken", "DPoP", "refreshToken", Date(expirationTime), "scope" + ) + val json = gson.toJson(credentials) + prepareJwtDecoderMock(Date(expirationTime)) + Mockito.`when`(crypto.encrypt(json.toByteArray())).thenReturn(json.toByteArray()) + whenever(mockDPoPKeyStore.hasKeyPair()).thenReturn(false) + + manager.saveCredentials(credentials) + + verify(storage).remove("com.auth0.dpop_key_thumbprint") + verify(storage, never()).store(eq("com.auth0.dpop_key_thumbprint"), anyString()) + } + + + @Test + public fun shouldFailOnGetCredentialsWithDPoPKeyMissingWhenKeyNotInKeyStore() { + Mockito.`when`(localAuthenticationManager.authenticate()).then { + localAuthenticationManager.resultCallback.onSuccess(true) + } + val expiresAt = Date(CredentialsMock.CURRENT_TIME_MS) // expired + insertTestCredentials(true, true, true, expiresAt, "scope") + Mockito.`when`(storage.retrieveString("com.auth0.token_type")).thenReturn("DPoP") + whenever(mockDPoPKeyStore.hasKeyPair()).thenReturn(false) + + manager.getCredentials(callback) + + verify(callback).onFailure(exceptionCaptor.capture()) + val exception = exceptionCaptor.firstValue + MatcherAssert.assertThat(exception, Is.`is`(CredentialsManagerException.DPOP_KEY_MISSING)) + } + + @Test + public fun shouldFailOnGetCredentialsWithDPoPNotConfiguredWhenClientNotSetup() { + Mockito.`when`(localAuthenticationManager.authenticate()).then { + localAuthenticationManager.resultCallback.onSuccess(true) + } + val expiresAt = Date(CredentialsMock.CURRENT_TIME_MS) // expired + insertTestCredentials(true, true, true, expiresAt, "scope") + Mockito.`when`(storage.retrieveString("com.auth0.token_type")).thenReturn("DPoP") + whenever(mockDPoPKeyStore.hasKeyPair()).thenReturn(true) + + manager.getCredentials(callback) + + verify(callback).onFailure(exceptionCaptor.capture()) + val exception = exceptionCaptor.firstValue + MatcherAssert.assertThat(exception, Is.`is`(CredentialsManagerException.DPOP_NOT_CONFIGURED)) + } + + @Test + public fun shouldFailOnGetCredentialsWithDPoPKeyMismatchWhenThumbprintsDontMatch() { + Mockito.`when`(localAuthenticationManager.authenticate()).then { + localAuthenticationManager.resultCallback.onSuccess(true) + } + val expiresAt = Date(CredentialsMock.CURRENT_TIME_MS) // expired + insertTestCredentials(true, true, true, expiresAt, "scope") + Mockito.`when`(storage.retrieveString("com.auth0.token_type")).thenReturn("DPoP") + Mockito.`when`(storage.retrieveString("com.auth0.dpop_key_thumbprint")) + .thenReturn("old-thumbprint-from-previous-key") + whenever(mockDPoPKeyStore.hasKeyPair()).thenReturn(true) + whenever(mockDPoPKeyStore.getKeyPair()).thenReturn(Pair(fakePrivateKey, fakePublicKey)) + Mockito.doReturn(true).`when`(client).isDPoPEnabled + + manager.getCredentials(callback) + + verify(callback).onFailure(exceptionCaptor.capture()) + val exception = exceptionCaptor.firstValue + MatcherAssert.assertThat(exception, Is.`is`(CredentialsManagerException.DPOP_KEY_MISMATCH)) + } + + @Test + public fun shouldBackfillDPoPThumbprintForMigrationScenario() { + val expiresAt = Date(CredentialsMock.CURRENT_TIME_MS) // expired + insertTestCredentials(true, true, true, expiresAt, "scope") + Mockito.`when`(storage.retrieveString("com.auth0.token_type")).thenReturn("DPoP") + // No stored thumbprint (migration scenario) + Mockito.`when`(storage.retrieveString("com.auth0.dpop_key_thumbprint")).thenReturn(null) + whenever(mockDPoPKeyStore.hasKeyPair()).thenReturn(true) + whenever(mockDPoPKeyStore.getKeyPair()).thenReturn(Pair(fakePrivateKey, fakePublicKey)) + Mockito.doReturn(true).`when`(client).isDPoPEnabled + Mockito.`when`(client.renewAuth("refreshToken")).thenReturn(request) + val newDate = Date(CredentialsMock.ONE_HOUR_AHEAD_MS + ONE_HOUR_SECONDS * 1000) + val jwtMock = mock() + Mockito.`when`(jwtMock.expiresAt).thenReturn(newDate) + Mockito.`when`(jwtDecoder.decode("newId")).thenReturn(jwtMock) + val renewedCredentials = + Credentials("newId", "newAccess", "DPoP", "newRefresh", newDate, "scope") + Mockito.`when`(request.execute()).thenReturn(renewedCredentials) + val expectedJson = gson.toJson(renewedCredentials) + Mockito.`when`(crypto.encrypt(any())).thenReturn(expectedJson.toByteArray()) + + manager.continueGetCredentials(null, 0, emptyMap(), emptyMap(), false, callback) + + // Verify thumbprint was backfilled during validation (and also stored again during saveCredentials after renewal) + verify(storage, Mockito.atLeastOnce()).store(eq("com.auth0.dpop_key_thumbprint"), anyString()) + verify(callback).onSuccess(credentialsCaptor.capture()) + } + + @Test + public fun shouldTriggerDPoPValidationViaStoredThumbprintEvenForBearerTokenType() { + val expiresAt = Date(CredentialsMock.CURRENT_TIME_MS) // expired + insertTestCredentials(true, true, true, expiresAt, "scope") + Mockito.`when`(storage.retrieveString("com.auth0.token_type")).thenReturn("Bearer") + // Thumbprint exists (DPoP was used, but token_type is Bearer — RFC 9449 edge case) + Mockito.`when`(storage.retrieveString("com.auth0.dpop_key_thumbprint")) + .thenReturn("some-thumbprint") + whenever(mockDPoPKeyStore.hasKeyPair()).thenReturn(false) + + manager.continueGetCredentials(null, 0, emptyMap(), emptyMap(), false, callback) + + verify(callback).onFailure(exceptionCaptor.capture()) + val exception = exceptionCaptor.firstValue + MatcherAssert.assertThat(exception, Is.`is`(CredentialsManagerException.DPOP_KEY_MISSING)) + } + + @Test + public fun shouldFailOnGetSsoCredentialsWithDPoPKeyMissingWhenKeyNotInKeyStore() { + val expiresAt = Date(CredentialsMock.ONE_HOUR_AHEAD_MS) + insertTestCredentials(true, true, true, expiresAt, "scope") + Mockito.`when`(storage.retrieveString("com.auth0.token_type")).thenReturn("DPoP") + whenever(mockDPoPKeyStore.hasKeyPair()).thenReturn(false) + + manager.getSsoCredentials(ssoCallback) + + verify(ssoCallback).onFailure(exceptionCaptor.capture()) + val exception = exceptionCaptor.firstValue + MatcherAssert.assertThat(exception, Is.`is`(CredentialsManagerException.DPOP_KEY_MISSING)) + } + + @Test + public fun shouldFailOnGetApiCredentialsWithDPoPKeyMissingWhenKeyNotInKeyStore() { + val accessTokenExpiry = CredentialsMock.CURRENT_TIME_MS // expired + val apiCredentials = ApiCredentialsMock.create( + accessToken = "apiToken", + type = "DPoP", + expiresAt = Date(accessTokenExpiry), + scope = "read:data" + ) + val storedJson = gson.toJson(apiCredentials) + val encoded = String(Base64.encode(storedJson.toByteArray(), Base64.DEFAULT)) + Mockito.`when`(crypto.decrypt(storedJson.toByteArray())) + .thenReturn(storedJson.toByteArray()) + Mockito.`when`(storage.retrieveString("audience::read:data")).thenReturn(encoded) + val expiresAt = Date(CredentialsMock.ONE_HOUR_AHEAD_MS) + insertTestCredentials(true, true, true, expiresAt, "scope") + whenever(mockDPoPKeyStore.hasKeyPair()).thenReturn(false) + + manager.continueGetApiCredentials("audience", "read:data", 0, emptyMap(), emptyMap(), apiCredentialsCallback) + + verify(apiCredentialsCallback).onFailure(exceptionCaptor.capture()) + val exception = exceptionCaptor.firstValue + MatcherAssert.assertThat(exception, Is.`is`(CredentialsManagerException.DPOP_KEY_MISSING)) + } + + @Test + public fun shouldUseApiCredentialTypeForDPoPValidationInsteadOfBaseTokenType() { + val accessTokenExpiry = CredentialsMock.CURRENT_TIME_MS // expired + val apiCredentials = ApiCredentialsMock.create( + accessToken = "apiToken", + type = "DPoP", + expiresAt = Date(accessTokenExpiry), + scope = "read:data" + ) + val storedJson = gson.toJson(apiCredentials) + val encoded = String(Base64.encode(storedJson.toByteArray(), Base64.DEFAULT)) + Mockito.`when`(crypto.decrypt(storedJson.toByteArray())) + .thenReturn(storedJson.toByteArray()) + Mockito.`when`(storage.retrieveString("audience::read:data")).thenReturn(encoded) + Mockito.`when`(storage.retrieveString("com.auth0.token_type")).thenReturn("Bearer") + val expiresAt = Date(CredentialsMock.ONE_HOUR_AHEAD_MS) + insertTestCredentials(true, true, true, expiresAt, "scope") + whenever(mockDPoPKeyStore.hasKeyPair()).thenReturn(false) + + manager.continueGetApiCredentials("audience", "read:data", 0, emptyMap(), emptyMap(), apiCredentialsCallback) + + verify(apiCredentialsCallback).onFailure(exceptionCaptor.capture()) + val exception = exceptionCaptor.firstValue + MatcherAssert.assertThat(exception, Is.`is`(CredentialsManagerException.DPOP_KEY_MISSING)) + } + + @After + public fun tearDown() { + DPoPUtil.keyStore = DPoPKeyStore() + } + private fun prepareJwtDecoderMock(expiresAt: Date?) { val jwtMock = mock() Mockito.`when`(jwtMock.expiresAt).thenReturn(expiresAt) From b38b374b413799f098cfe1b373b85291c89f5777 Mon Sep 17 00:00:00 2001 From: Prince Mathew <17837162+pmathew92@users.noreply.github.com> Date: Wed, 15 Apr 2026 12:51:01 +0530 Subject: [PATCH 19/60] Release 3.15.0 (#952) --- .version | 2 +- CHANGELOG.md | 12 ++++++++++++ README.md | 2 +- 3 files changed, 14 insertions(+), 2 deletions(-) diff --git a/.version b/.version index 12566ed7f..4bcbd94b5 100644 --- a/.version +++ b/.version @@ -1 +1 @@ -3.14.0 \ No newline at end of file +3.15.0 \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 2d25046e0..da3c6a5e2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,17 @@ # Change Log +## [3.15.0](https://github.com/auth0/Auth0.Android/tree/3.15.0) (2026-04-15) +[Full Changelog](https://github.com/auth0/Auth0.Android/compare/3.14.0...3.15.0) + +**Added** +- feat: Add three new DPoP errors to `CredentialsManagerException` [\#949](https://github.com/auth0/Auth0.Android/pull/949) ([pmathew92](https://github.com/pmathew92)) + +**Changed** +- refactor : Made the updateAuthenticationMethodById public [\#948](https://github.com/auth0/Auth0.Android/pull/948) ([pmathew92](https://github.com/pmathew92)) + +**Fixed** +- refactor : Handling more exceptions scenario in DPoP key generation [\#943](https://github.com/auth0/Auth0.Android/pull/943) ([pmathew92](https://github.com/pmathew92)) + ## [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) diff --git a/README.md b/README.md index 3add05892..8325e7e39 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.14.0' + implementation 'com.auth0.android:auth0:3.15.0' } ``` From f15251e3c0debd2c2c9b1bb51e289dfc91e8b021 Mon Sep 17 00:00:00 2001 From: Subhankar Maiti Date: Thu, 16 Apr 2026 23:50:05 +0530 Subject: [PATCH 20/60] feat: Implement Partial Custom Tabs with customizable dimensions and background interaction --- auth0/build.gradle | 2 +- .../android/provider/CustomTabsOptions.java | 178 ++++++++++++- .../provider/CustomTabsOptionsTest.java | 238 ++++++++++++++++++ 3 files changed, 415 insertions(+), 3 deletions(-) diff --git a/auth0/build.gradle b/auth0/build.gradle index e3b5cae26..edb14a9a3 100644 --- a/auth0/build.gradle +++ b/auth0/build.gradle @@ -87,7 +87,7 @@ dependencies { implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version" implementation 'androidx.core:core-ktx:1.6.0' implementation 'androidx.appcompat:appcompat:1.6.0' - implementation 'androidx.browser:browser:1.4.0' + implementation 'androidx.browser:browser:1.8.0' implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutinesVersion" implementation "com.squareup.okhttp3:okhttp:$okhttpVersion" diff --git a/auth0/src/main/java/com/auth0/android/provider/CustomTabsOptions.java b/auth0/src/main/java/com/auth0/android/provider/CustomTabsOptions.java index 765eed204..627a5d95b 100644 --- a/auth0/src/main/java/com/auth0/android/provider/CustomTabsOptions.java +++ b/auth0/src/main/java/com/auth0/android/provider/CustomTabsOptions.java @@ -9,6 +9,7 @@ import android.os.Parcelable; import androidx.annotation.ColorRes; +import androidx.annotation.Dimension; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.browser.customtabs.CustomTabColorSchemeParams; @@ -21,6 +22,8 @@ import java.util.List; +import android.util.DisplayMetrics; + /** * Holder for Custom Tabs customization options. Use {@link CustomTabsOptions#newBuilder()} to begin. */ @@ -34,11 +37,38 @@ public class CustomTabsOptions implements Parcelable { @Nullable private final List disabledCustomTabsPackages; - private CustomTabsOptions(boolean showTitle, @ColorRes int toolbarColor, @NonNull BrowserPicker browserPicker, @Nullable List disabledCustomTabsPackages) { + // Partial Custom Tabs - Bottom Sheet + private final int initialHeight; + private final int activityHeightResizeBehavior; + private final int toolbarCornerRadius; + + // Partial Custom Tabs - Side Sheet + private final int initialWidth; + private final int sideSheetBreakpoint; + + // Partial Custom Tabs - Background Interaction + private final boolean backgroundInteractionEnabled; + + private CustomTabsOptions(boolean showTitle, @ColorRes int toolbarColor, @NonNull BrowserPicker browserPicker, + @Nullable List disabledCustomTabsPackages, + int initialHeight, int activityHeightResizeBehavior, int toolbarCornerRadius, + int initialWidth, int sideSheetBreakpoint, + boolean backgroundInteractionEnabled) { this.showTitle = showTitle; this.toolbarColor = toolbarColor; this.browserPicker = browserPicker; this.disabledCustomTabsPackages = disabledCustomTabsPackages; + this.initialHeight = initialHeight; + this.activityHeightResizeBehavior = activityHeightResizeBehavior; + this.toolbarCornerRadius = toolbarCornerRadius; + this.initialWidth = initialWidth; + this.sideSheetBreakpoint = sideSheetBreakpoint; + this.backgroundInteractionEnabled = backgroundInteractionEnabled; + } + + private static int dpToPx(@NonNull Context context, int dp) { + final DisplayMetrics metrics = context.getResources().getDisplayMetrics(); + return Math.round(dp * metrics.density); } @Nullable @@ -88,6 +118,28 @@ Intent toIntent(@NonNull Context context, @Nullable CustomTabsSession session) { .setToolbarColor(ContextCompat.getColor(context, toolbarColor)); builder.setDefaultColorSchemeParams(colorBuilder.build()); } + + // Partial Custom Tabs - Bottom Sheet + if (initialHeight > 0) { + builder.setInitialActivityHeightPx(dpToPx(context, initialHeight), activityHeightResizeBehavior); + } + if (toolbarCornerRadius > 0) { + builder.setToolbarCornerRadiusDp(toolbarCornerRadius); + } + + // Partial Custom Tabs - Side Sheet + if (initialWidth > 0) { + builder.setInitialActivityWidthPx(dpToPx(context, initialWidth)); + } + if (sideSheetBreakpoint > 0) { + builder.setActivitySideSheetBreakpointDp(sideSheetBreakpoint); + } + + // Partial Custom Tabs - Background Interaction + if (backgroundInteractionEnabled) { + builder.setBackgroundInteractionEnabled(true); + } + return builder.build().intent; } @@ -108,6 +160,12 @@ protected CustomTabsOptions(@NonNull Parcel in) { toolbarColor = in.readInt(); browserPicker = in.readParcelable(BrowserPicker.class.getClassLoader()); disabledCustomTabsPackages = in.createStringArrayList(); + initialHeight = in.readInt(); + activityHeightResizeBehavior = in.readInt(); + toolbarCornerRadius = in.readInt(); + initialWidth = in.readInt(); + sideSheetBreakpoint = in.readInt(); + backgroundInteractionEnabled = in.readByte() != 0; } @Override @@ -116,6 +174,12 @@ public void writeToParcel(@NonNull Parcel dest, int flags) { dest.writeInt(toolbarColor); dest.writeParcelable(browserPicker, flags); dest.writeStringList(disabledCustomTabsPackages); + dest.writeInt(initialHeight); + dest.writeInt(activityHeightResizeBehavior); + dest.writeInt(toolbarCornerRadius); + dest.writeInt(initialWidth); + dest.writeInt(sideSheetBreakpoint); + dest.writeByte((byte) (backgroundInteractionEnabled ? 1 : 0)); } @Override @@ -147,11 +211,24 @@ public static class Builder { @Nullable private List disabledCustomTabsPackages; + private int initialHeight; + private int activityHeightResizeBehavior; + private int toolbarCornerRadius; + private int initialWidth; + private int sideSheetBreakpoint; + private boolean backgroundInteractionEnabled; + Builder() { this.showTitle = false; this.toolbarColor = 0; this.browserPicker = BrowserPicker.newBuilder().build(); this.disabledCustomTabsPackages = null; + this.initialHeight = 0; + this.activityHeightResizeBehavior = CustomTabsIntent.ACTIVITY_HEIGHT_DEFAULT; + this.toolbarCornerRadius = 0; + this.initialWidth = 0; + this.sideSheetBreakpoint = 0; + this.backgroundInteractionEnabled = false; } /** @@ -212,6 +289,101 @@ public Builder withDisabledCustomTabsPackages(List disabledCustomTabsPac return this; } + /** + * Sets the initial height for the Custom Tab to display as a bottom sheet. + * When set, the Custom Tab will appear as a bottom sheet instead of full screen. + * Pass the size in dp; it will be converted to pixels internally. + * The minimum height enforced by Chrome is 50% of the screen; values below this are auto-adjusted. + * Falls back to full screen on browsers that don't support Partial Custom Tabs (requires Chrome 107+). + * By default, the bottom sheet is resizable by the user. Use {@link #withResizableHeight(boolean)} + * to lock the height. + * + * @param height the initial bottom sheet height in dp. + * @return this same builder instance. + */ + @NonNull + public Builder withInitialHeight(@Dimension(unit = Dimension.DP) int height) { + this.initialHeight = height; + return this; + } + + /** + * Sets whether the user can resize the Partial Custom Tab by dragging. + * For bottom sheets, this controls whether the user can drag the toolbar handle to + * expand or collapse the sheet. For side sheets, this controls whether the sheet + * can be resized. By default, the Partial Custom Tab is resizable. + * Pass {@code false} to lock the size. Only takes effect when + * {@link #withInitialHeight(int)} is also set. + * + * @param resizable whether the Partial Custom Tab should be resizable. + * @return this same builder instance. + */ + @NonNull + public Builder withResizable(boolean resizable) { + this.activityHeightResizeBehavior = resizable + ? CustomTabsIntent.ACTIVITY_HEIGHT_ADJUSTABLE + : CustomTabsIntent.ACTIVITY_HEIGHT_FIXED; + return this; + } + + /** + * Sets the toolbar's top corner radii in dp. Only takes effect when the Custom Tab is + * displayed as a bottom sheet (i.e., when {@link #withInitialHeight(int)} is also set). + * Pass the size in dp. + * + * @param cornerRadius the toolbar's top corner radius in dp. + * @return this same builder instance. + */ + @NonNull + public Builder withToolbarCornerRadius(@Dimension(unit = Dimension.DP) int cornerRadius) { + this.toolbarCornerRadius = cornerRadius; + return this; + } + + /** + * Sets the initial width for the Custom Tab to display as a side sheet on larger screens. + * The Custom Tab will behave as a side sheet if the screen's width is bigger than the + * breakpoint value set by {@link #withSideSheetBreakpoint(int)}. + * Pass the size in dp; it will be converted to pixels internally. + * Falls back to bottom sheet or full screen on unsupported browsers. + * + * @param width the initial side sheet width in dp. + * @return this same builder instance. + */ + @NonNull + public Builder withInitialWidth(@Dimension(unit = Dimension.DP) int width) { + this.initialWidth = width; + return this; + } + + /** + * Sets the breakpoint in dp to switch between bottom sheet and side sheet mode. + * If the screen's width is bigger than this value, the Custom Tab will behave as a side sheet; + * otherwise it will behave as a bottom sheet. The browser default is typically 840dp. + * Pass the size in dp. + * + * @param breakpoint the breakpoint in dp. + * @return this same builder instance. + */ + @NonNull + public Builder withSideSheetBreakpoint(@Dimension(unit = Dimension.DP) int breakpoint) { + this.sideSheetBreakpoint = breakpoint; + return this; + } + + /** + * Enables or disables interaction with the background app when a Partial Custom Tab is displayed. + * By default, background interaction is disabled. + * + * @param enabled whether to enable interaction with the app behind the partial tab. + * @return this same builder instance. + */ + @NonNull + public Builder withBackgroundInteractionEnabled(boolean enabled) { + this.backgroundInteractionEnabled = enabled; + return this; + } + /** * Create a new CustomTabsOptions instance with the customization settings. * @@ -219,7 +391,9 @@ public Builder withDisabledCustomTabsPackages(List disabledCustomTabsPac */ @NonNull public CustomTabsOptions build() { - return new CustomTabsOptions(showTitle, toolbarColor, browserPicker, disabledCustomTabsPackages); + return new CustomTabsOptions(showTitle, toolbarColor, browserPicker, disabledCustomTabsPackages, + initialHeight, activityHeightResizeBehavior, toolbarCornerRadius, + initialWidth, sideSheetBreakpoint, backgroundInteractionEnabled); } } diff --git a/auth0/src/test/java/com/auth0/android/provider/CustomTabsOptionsTest.java b/auth0/src/test/java/com/auth0/android/provider/CustomTabsOptionsTest.java index c4f03efb4..ca1f72701 100644 --- a/auth0/src/test/java/com/auth0/android/provider/CustomTabsOptionsTest.java +++ b/auth0/src/test/java/com/auth0/android/provider/CustomTabsOptionsTest.java @@ -87,6 +87,13 @@ public void shouldHaveDefaultValues() { assertThat(intent.getIntExtra(CustomTabsIntent.EXTRA_TITLE_VISIBILITY_STATE, CustomTabsIntent.NO_TITLE), is(CustomTabsIntent.NO_TITLE)); assertThat(intent.getIntExtra(CustomTabsIntent.EXTRA_SHARE_STATE, CustomTabsIntent.SHARE_STATE_OFF), is(CustomTabsIntent.SHARE_STATE_OFF)); + // Should not have partial custom tab extras when not set + assertThat(intent.hasExtra(CustomTabsIntent.EXTRA_INITIAL_ACTIVITY_HEIGHT_PX), is(false)); + assertThat(intent.hasExtra(CustomTabsIntent.EXTRA_TOOLBAR_CORNER_RADIUS_DP), is(false)); + assertThat(intent.hasExtra(CustomTabsIntent.EXTRA_INITIAL_ACTIVITY_WIDTH_PX), is(false)); + assertThat(intent.hasExtra(CustomTabsIntent.EXTRA_ACTIVITY_SIDE_SHEET_BREAKPOINT_DP), is(false)); + assertThat(intent.hasExtra(CustomTabsIntent.EXTRA_DISABLE_BACKGROUND_INTERACTION), is(false)); + Parcel parcel = Parcel.obtain(); options.writeToParcel(parcel, 0); parcel.setDataPosition(0); @@ -100,6 +107,13 @@ public void shouldHaveDefaultValues() { assertThat(parceledIntent.getIntExtra(CustomTabsIntent.EXTRA_TOOLBAR_COLOR, 0), is(0)); assertThat(parceledIntent.getIntExtra(CustomTabsIntent.EXTRA_TITLE_VISIBILITY_STATE, CustomTabsIntent.NO_TITLE), is(CustomTabsIntent.NO_TITLE)); assertThat(parceledIntent.getIntExtra(CustomTabsIntent.EXTRA_SHARE_STATE, CustomTabsIntent.SHARE_STATE_OFF), is(CustomTabsIntent.SHARE_STATE_OFF)); + + // Parceled intent should also not have partial custom tab extras + assertThat(parceledIntent.hasExtra(CustomTabsIntent.EXTRA_INITIAL_ACTIVITY_HEIGHT_PX), is(false)); + assertThat(parceledIntent.hasExtra(CustomTabsIntent.EXTRA_TOOLBAR_CORNER_RADIUS_DP), is(false)); + assertThat(parceledIntent.hasExtra(CustomTabsIntent.EXTRA_INITIAL_ACTIVITY_WIDTH_PX), is(false)); + assertThat(parceledIntent.hasExtra(CustomTabsIntent.EXTRA_ACTIVITY_SIDE_SHEET_BREAKPOINT_DP), is(false)); + assertThat(parceledIntent.hasExtra(CustomTabsIntent.EXTRA_DISABLE_BACKGROUND_INTERACTION), is(false)); } @Test @@ -224,4 +238,228 @@ public void shouldSetDisabledCustomTabPackages() { int resolvedColor = ContextCompat.getColor(activity, android.R.color.black); assertThat(intentWithToolbarExtra.getIntExtra(CustomTabsIntent.EXTRA_TOOLBAR_COLOR, 0), is(resolvedColor)); } + + // --- Partial Custom Tabs: Bottom Sheet --- + // Note: Robolectric uses density=1.0 (mdpi) by default, so dp values equal px values in tests. + + @Test + public void shouldSetInitialActivityHeight() { + CustomTabsOptions options = CustomTabsOptions.newBuilder() + .withInitialHeight(800) + .build(); + assertThat(options, is(notNullValue())); + + Intent intent = options.toIntent(context, null); + assertThat(intent, is(notNullValue())); + assertThat(intent.hasExtra(CustomTabsIntent.EXTRA_INITIAL_ACTIVITY_HEIGHT_PX), is(true)); + assertThat(intent.getIntExtra(CustomTabsIntent.EXTRA_INITIAL_ACTIVITY_HEIGHT_PX, 0), is(800)); + + Parcel parcel = Parcel.obtain(); + options.writeToParcel(parcel, 0); + parcel.setDataPosition(0); + CustomTabsOptions parceledOptions = CustomTabsOptions.CREATOR.createFromParcel(parcel); + Intent parceledIntent = parceledOptions.toIntent(context, null); + assertThat(parceledIntent.hasExtra(CustomTabsIntent.EXTRA_INITIAL_ACTIVITY_HEIGHT_PX), is(true)); + assertThat(parceledIntent.getIntExtra(CustomTabsIntent.EXTRA_INITIAL_ACTIVITY_HEIGHT_PX, 0), is(800)); + } + + @Test + public void shouldSetInitialActivityHeightWithFixedResize() { + CustomTabsOptions options = CustomTabsOptions.newBuilder() + .withInitialHeight(600) + .withResizable(false) + .build(); + assertThat(options, is(notNullValue())); + + Intent intent = options.toIntent(context, null); + assertThat(intent, is(notNullValue())); + assertThat(intent.hasExtra(CustomTabsIntent.EXTRA_INITIAL_ACTIVITY_HEIGHT_PX), is(true)); + assertThat(intent.getIntExtra(CustomTabsIntent.EXTRA_INITIAL_ACTIVITY_HEIGHT_PX, 0), is(600)); + assertThat(intent.getIntExtra(CustomTabsIntent.EXTRA_ACTIVITY_HEIGHT_RESIZE_BEHAVIOR, CustomTabsIntent.ACTIVITY_HEIGHT_DEFAULT), is(CustomTabsIntent.ACTIVITY_HEIGHT_FIXED)); + + Parcel parcel = Parcel.obtain(); + options.writeToParcel(parcel, 0); + parcel.setDataPosition(0); + CustomTabsOptions parceledOptions = CustomTabsOptions.CREATOR.createFromParcel(parcel); + Intent parceledIntent = parceledOptions.toIntent(context, null); + assertThat(parceledIntent.getIntExtra(CustomTabsIntent.EXTRA_INITIAL_ACTIVITY_HEIGHT_PX, 0), is(600)); + assertThat(parceledIntent.getIntExtra(CustomTabsIntent.EXTRA_ACTIVITY_HEIGHT_RESIZE_BEHAVIOR, CustomTabsIntent.ACTIVITY_HEIGHT_DEFAULT), is(CustomTabsIntent.ACTIVITY_HEIGHT_FIXED)); + } + + @Test + public void shouldSetToolbarCornerRadius() { + CustomTabsOptions options = CustomTabsOptions.newBuilder() + .withInitialHeight(800) + .withToolbarCornerRadius(16) + .build(); + assertThat(options, is(notNullValue())); + + Intent intent = options.toIntent(context, null); + assertThat(intent, is(notNullValue())); + assertThat(intent.hasExtra(CustomTabsIntent.EXTRA_TOOLBAR_CORNER_RADIUS_DP), is(true)); + assertThat(intent.getIntExtra(CustomTabsIntent.EXTRA_TOOLBAR_CORNER_RADIUS_DP, 0), is(16)); + + Parcel parcel = Parcel.obtain(); + options.writeToParcel(parcel, 0); + parcel.setDataPosition(0); + CustomTabsOptions parceledOptions = CustomTabsOptions.CREATOR.createFromParcel(parcel); + Intent parceledIntent = parceledOptions.toIntent(context, null); + assertThat(parceledIntent.hasExtra(CustomTabsIntent.EXTRA_TOOLBAR_CORNER_RADIUS_DP), is(true)); + assertThat(parceledIntent.getIntExtra(CustomTabsIntent.EXTRA_TOOLBAR_CORNER_RADIUS_DP, 0), is(16)); + } + + // --- Partial Custom Tabs: Side Sheet --- + // Note: withInitialWidth accepts dp; converted to px internally (1:1 in Robolectric mdpi). + + @Test + public void shouldSetInitialActivityWidth() { + CustomTabsOptions options = CustomTabsOptions.newBuilder() + .withInitialHeight(600) + .withInitialWidth(400) + .build(); + assertThat(options, is(notNullValue())); + + Intent intent = options.toIntent(context, null); + assertThat(intent, is(notNullValue())); + assertThat(intent.hasExtra(CustomTabsIntent.EXTRA_INITIAL_ACTIVITY_WIDTH_PX), is(true)); + assertThat(intent.getIntExtra(CustomTabsIntent.EXTRA_INITIAL_ACTIVITY_WIDTH_PX, 0), is(400)); + + Parcel parcel = Parcel.obtain(); + options.writeToParcel(parcel, 0); + parcel.setDataPosition(0); + CustomTabsOptions parceledOptions = CustomTabsOptions.CREATOR.createFromParcel(parcel); + Intent parceledIntent = parceledOptions.toIntent(context, null); + assertThat(parceledIntent.hasExtra(CustomTabsIntent.EXTRA_INITIAL_ACTIVITY_WIDTH_PX), is(true)); + assertThat(parceledIntent.getIntExtra(CustomTabsIntent.EXTRA_INITIAL_ACTIVITY_WIDTH_PX, 0), is(400)); + } + + @Test + public void shouldSetSideSheetBreakpoint() { + CustomTabsOptions options = CustomTabsOptions.newBuilder() + .withInitialHeight(600) + .withInitialWidth(400) + .withSideSheetBreakpoint(840) + .build(); + assertThat(options, is(notNullValue())); + + Intent intent = options.toIntent(context, null); + assertThat(intent, is(notNullValue())); + assertThat(intent.hasExtra(CustomTabsIntent.EXTRA_ACTIVITY_SIDE_SHEET_BREAKPOINT_DP), is(true)); + assertThat(intent.getIntExtra(CustomTabsIntent.EXTRA_ACTIVITY_SIDE_SHEET_BREAKPOINT_DP, 0), is(840)); + + Parcel parcel = Parcel.obtain(); + options.writeToParcel(parcel, 0); + parcel.setDataPosition(0); + CustomTabsOptions parceledOptions = CustomTabsOptions.CREATOR.createFromParcel(parcel); + Intent parceledIntent = parceledOptions.toIntent(context, null); + assertThat(parceledIntent.hasExtra(CustomTabsIntent.EXTRA_ACTIVITY_SIDE_SHEET_BREAKPOINT_DP), is(true)); + assertThat(parceledIntent.getIntExtra(CustomTabsIntent.EXTRA_ACTIVITY_SIDE_SHEET_BREAKPOINT_DP, 0), is(840)); + } + + // --- Partial Custom Tabs: Background Interaction --- + + @Test + public void shouldSetBackgroundInteractionEnabled() { + CustomTabsOptions options = CustomTabsOptions.newBuilder() + .withInitialHeight(800) + .withBackgroundInteractionEnabled(true) + .build(); + assertThat(options, is(notNullValue())); + + Intent intent = options.toIntent(context, null); + assertThat(intent, is(notNullValue())); + // When background interaction is enabled, EXTRA_DISABLE_BACKGROUND_INTERACTION should be present and false + assertThat(intent.hasExtra(CustomTabsIntent.EXTRA_DISABLE_BACKGROUND_INTERACTION), is(true)); + + Parcel parcel = Parcel.obtain(); + options.writeToParcel(parcel, 0); + parcel.setDataPosition(0); + CustomTabsOptions parceledOptions = CustomTabsOptions.CREATOR.createFromParcel(parcel); + Intent parceledIntent = parceledOptions.toIntent(context, null); + assertThat(parceledIntent.hasExtra(CustomTabsIntent.EXTRA_DISABLE_BACKGROUND_INTERACTION), is(true)); + } + + @Test + public void shouldNotSetBackgroundInteractionWhenDisabled() { + CustomTabsOptions options = CustomTabsOptions.newBuilder() + .withInitialHeight(800) + .withBackgroundInteractionEnabled(false) + .build(); + assertThat(options, is(notNullValue())); + + Intent intent = options.toIntent(context, null); + assertThat(intent, is(notNullValue())); + assertThat(intent.hasExtra(CustomTabsIntent.EXTRA_DISABLE_BACKGROUND_INTERACTION), is(false)); + } + + // --- Combined Partial Custom Tabs --- + + @Test + public void shouldSetAllPartialCustomTabOptions() { + CustomTabsOptions options = CustomTabsOptions.newBuilder() + .withToolbarColor(android.R.color.black) + .showTitle(true) + .withInitialHeight(800) + .withResizable(false) + .withToolbarCornerRadius(16) + .withInitialWidth(500) + .withSideSheetBreakpoint(840) + .withBackgroundInteractionEnabled(true) + .build(); + assertThat(options, is(notNullValue())); + + Intent intent = options.toIntent(context, null); + assertThat(intent, is(notNullValue())); + + // Existing options + assertThat(intent.hasExtra(CustomTabsIntent.EXTRA_TOOLBAR_COLOR), is(true)); + assertThat(intent.getIntExtra(CustomTabsIntent.EXTRA_TITLE_VISIBILITY_STATE, CustomTabsIntent.NO_TITLE), is(CustomTabsIntent.SHOW_PAGE_TITLE)); + + // Bottom sheet + assertThat(intent.getIntExtra(CustomTabsIntent.EXTRA_INITIAL_ACTIVITY_HEIGHT_PX, 0), is(800)); + assertThat(intent.getIntExtra(CustomTabsIntent.EXTRA_ACTIVITY_HEIGHT_RESIZE_BEHAVIOR, CustomTabsIntent.ACTIVITY_HEIGHT_DEFAULT), is(CustomTabsIntent.ACTIVITY_HEIGHT_FIXED)); + assertThat(intent.getIntExtra(CustomTabsIntent.EXTRA_TOOLBAR_CORNER_RADIUS_DP, 0), is(16)); + + // Side sheet + assertThat(intent.getIntExtra(CustomTabsIntent.EXTRA_INITIAL_ACTIVITY_WIDTH_PX, 0), is(500)); + assertThat(intent.getIntExtra(CustomTabsIntent.EXTRA_ACTIVITY_SIDE_SHEET_BREAKPOINT_DP, 0), is(840)); + + // Background interaction + assertThat(intent.hasExtra(CustomTabsIntent.EXTRA_DISABLE_BACKGROUND_INTERACTION), is(true)); + + // Verify parceling preserves everything + Parcel parcel = Parcel.obtain(); + options.writeToParcel(parcel, 0); + parcel.setDataPosition(0); + CustomTabsOptions parceledOptions = CustomTabsOptions.CREATOR.createFromParcel(parcel); + Intent parceledIntent = parceledOptions.toIntent(context, null); + + assertThat(parceledIntent.hasExtra(CustomTabsIntent.EXTRA_TOOLBAR_COLOR), is(true)); + assertThat(parceledIntent.getIntExtra(CustomTabsIntent.EXTRA_INITIAL_ACTIVITY_HEIGHT_PX, 0), is(800)); + assertThat(parceledIntent.getIntExtra(CustomTabsIntent.EXTRA_ACTIVITY_HEIGHT_RESIZE_BEHAVIOR, CustomTabsIntent.ACTIVITY_HEIGHT_DEFAULT), is(CustomTabsIntent.ACTIVITY_HEIGHT_FIXED)); + assertThat(parceledIntent.getIntExtra(CustomTabsIntent.EXTRA_TOOLBAR_CORNER_RADIUS_DP, 0), is(16)); + assertThat(parceledIntent.getIntExtra(CustomTabsIntent.EXTRA_INITIAL_ACTIVITY_WIDTH_PX, 0), is(500)); + assertThat(parceledIntent.getIntExtra(CustomTabsIntent.EXTRA_ACTIVITY_SIDE_SHEET_BREAKPOINT_DP, 0), is(840)); + assertThat(parceledIntent.hasExtra(CustomTabsIntent.EXTRA_DISABLE_BACKGROUND_INTERACTION), is(true)); + } + + @Test + public void shouldNotSetPartialOptionsWhenDisabledBrowser() { + Activity activity = spy(Robolectric.setupActivity(Activity.class)); + BrowserPickerTest.setupBrowserContext(activity, Collections.singletonList("com.auth0.browser"), null, null); + BrowserPicker browserPicker = BrowserPicker.newBuilder().build(); + + CustomTabsOptions options = CustomTabsOptions.newBuilder() + .withBrowserPicker(browserPicker) + .withDisabledCustomTabsPackages(List.of("com.auth0.browser")) + .withInitialHeight(800) + .withToolbarCornerRadius(16) + .build(); + assertThat(options, is(notNullValue())); + + Intent intentNoExtras = options.toIntent(activity, null); + assertThat(intentNoExtras, is(notNullValue())); + assertThat(intentNoExtras.getExtras(), is(nullValue())); + assertEquals(intentNoExtras.getAction(), "android.intent.action.VIEW"); + } } \ No newline at end of file From e458f71e3920d6a237ccb337b87ae0340caa5fb7 Mon Sep 17 00:00:00 2001 From: Subhankar Maiti Date: Fri, 17 Apr 2026 14:52:02 +0530 Subject: [PATCH 21/60] addressed pr comments --- .../android/provider/CustomTabsOptions.java | 24 ++++++++++++------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/auth0/src/main/java/com/auth0/android/provider/CustomTabsOptions.java b/auth0/src/main/java/com/auth0/android/provider/CustomTabsOptions.java index 627a5d95b..7835402f4 100644 --- a/auth0/src/main/java/com/auth0/android/provider/CustomTabsOptions.java +++ b/auth0/src/main/java/com/auth0/android/provider/CustomTabsOptions.java @@ -66,11 +66,6 @@ private CustomTabsOptions(boolean showTitle, @ColorRes int toolbarColor, @NonNul this.backgroundInteractionEnabled = backgroundInteractionEnabled; } - private static int dpToPx(@NonNull Context context, int dp) { - final DisplayMetrics metrics = context.getResources().getDisplayMetrics(); - return Math.round(dp * metrics.density); - } - @Nullable String getPreferredPackage(@NonNull PackageManager pm) { return browserPicker.getBestBrowserPackage(pm); @@ -342,8 +337,11 @@ public Builder withToolbarCornerRadius(@Dimension(unit = Dimension.DP) int corne /** * Sets the initial width for the Custom Tab to display as a side sheet on larger screens. - * The Custom Tab will behave as a side sheet if the screen's width is bigger than the - * breakpoint value set by {@link #withSideSheetBreakpoint(int)}. + * The Custom Tab will behave as a side sheet only if the screen's width is bigger than + * the breakpoint value set by {@link #withSideSheetBreakpoint(int)}. If no breakpoint is + * explicitly set, the browser's default breakpoint (typically 840dp in Chrome) is used, + * so smaller-width devices will continue to render as a bottom sheet or full screen + * rather than as a side sheet. * Pass the size in dp; it will be converted to pixels internally. * Falls back to bottom sheet or full screen on unsupported browsers. * @@ -359,8 +357,12 @@ public Builder withInitialWidth(@Dimension(unit = Dimension.DP) int width) { /** * Sets the breakpoint in dp to switch between bottom sheet and side sheet mode. * If the screen's width is bigger than this value, the Custom Tab will behave as a side sheet; - * otherwise it will behave as a bottom sheet. The browser default is typically 840dp. - * Pass the size in dp. + * otherwise it will behave as a bottom sheet. + *

+ * When this method is not called (or the value is left at the default {@code 0}), the + * breakpoint is not overridden and the browser's built-in default (typically + * {@code 840dp} in Chrome) is applied. This means devices with a screen width smaller + * than the browser default will still render as a bottom sheet, not a side sheet. * * @param breakpoint the breakpoint in dp. * @return this same builder instance. @@ -396,5 +398,9 @@ public CustomTabsOptions build() { initialWidth, sideSheetBreakpoint, backgroundInteractionEnabled); } } + private int dpToPx(@NonNull Context context, int dp) { + final DisplayMetrics metrics = context.getResources().getDisplayMetrics(); + return Math.round(dp * metrics.density); + } } From 964a4e4615dd6e104caa703d1d55a8f67f5a16f6 Mon Sep 17 00:00:00 2001 From: Subhankar Maiti Date: Fri, 17 Apr 2026 15:04:27 +0530 Subject: [PATCH 22/60] fix: Update CustomTabsOptions to use Collections.singletonList for disabled packages and add test for adjustable height --- .../android/provider/CustomTabsOptions.java | 10 ++++---- .../provider/CustomTabsOptionsTest.java | 24 +++++++++++++++---- 2 files changed, 26 insertions(+), 8 deletions(-) diff --git a/auth0/src/main/java/com/auth0/android/provider/CustomTabsOptions.java b/auth0/src/main/java/com/auth0/android/provider/CustomTabsOptions.java index 7835402f4..83a818a94 100644 --- a/auth0/src/main/java/com/auth0/android/provider/CustomTabsOptions.java +++ b/auth0/src/main/java/com/auth0/android/provider/CustomTabsOptions.java @@ -7,6 +7,7 @@ import android.net.Uri; import android.os.Parcel; import android.os.Parcelable; +import android.util.DisplayMetrics; import androidx.annotation.ColorRes; import androidx.annotation.Dimension; @@ -22,8 +23,6 @@ import java.util.List; -import android.util.DisplayMetrics; - /** * Holder for Custom Tabs customization options. Use {@link CustomTabsOptions#newBuilder()} to begin. */ @@ -290,7 +289,7 @@ public Builder withDisabledCustomTabsPackages(List disabledCustomTabsPac * Pass the size in dp; it will be converted to pixels internally. * The minimum height enforced by Chrome is 50% of the screen; values below this are auto-adjusted. * Falls back to full screen on browsers that don't support Partial Custom Tabs (requires Chrome 107+). - * By default, the bottom sheet is resizable by the user. Use {@link #withResizableHeight(boolean)} + * By default, the bottom sheet is resizable by the user. Use {@link #withResizable(boolean)} * to lock the height. * * @param height the initial bottom sheet height in dp. @@ -324,7 +323,10 @@ public Builder withResizable(boolean resizable) { /** * Sets the toolbar's top corner radii in dp. Only takes effect when the Custom Tab is * displayed as a bottom sheet (i.e., when {@link #withInitialHeight(int)} is also set). - * Pass the size in dp. + * Pass the size in dp. The underlying + * {@link CustomTabsIntent.Builder#setToolbarCornerRadiusDp(int)} currently accepts + * values in the range {@code 0}–{@code 16} (inclusive) and will throw an + * {@link IllegalArgumentException} for values outside that range. * * @param cornerRadius the toolbar's top corner radius in dp. * @return this same builder instance. diff --git a/auth0/src/test/java/com/auth0/android/provider/CustomTabsOptionsTest.java b/auth0/src/test/java/com/auth0/android/provider/CustomTabsOptionsTest.java index ca1f72701..cf1c3c9a2 100644 --- a/auth0/src/test/java/com/auth0/android/provider/CustomTabsOptionsTest.java +++ b/auth0/src/test/java/com/auth0/android/provider/CustomTabsOptionsTest.java @@ -201,7 +201,7 @@ public void shouldSetDisabledCustomTabPackages() { CustomTabsOptions options = CustomTabsOptions.newBuilder() .withBrowserPicker(browserPicker) - .withDisabledCustomTabsPackages(List.of("com.auth0.browser")) + .withDisabledCustomTabsPackages(Collections.singletonList("com.auth0.browser")) .withToolbarColor(android.R.color.black) .build(); assertThat(options, is(notNullValue())); @@ -228,7 +228,7 @@ public void shouldSetDisabledCustomTabPackages() { CustomTabsOptions options2 = CustomTabsOptions.newBuilder() .withBrowserPicker(browserPicker2) - .withDisabledCustomTabsPackages(List.of("com.auth0.browser")) + .withDisabledCustomTabsPackages(Collections.singletonList("com.auth0.browser")) .withToolbarColor(android.R.color.black) .build(); @@ -286,6 +286,20 @@ public void shouldSetInitialActivityHeightWithFixedResize() { assertThat(parceledIntent.getIntExtra(CustomTabsIntent.EXTRA_ACTIVITY_HEIGHT_RESIZE_BEHAVIOR, CustomTabsIntent.ACTIVITY_HEIGHT_DEFAULT), is(CustomTabsIntent.ACTIVITY_HEIGHT_FIXED)); } + @Test + public void shouldSetInitialActivityHeightWithAdjustableResize() { + CustomTabsOptions options = CustomTabsOptions.newBuilder() + .withInitialHeight(600) + .withResizable(true) + .build(); + assertThat(options, is(notNullValue())); + + Intent intent = options.toIntent(context, null); + assertThat(intent, is(notNullValue())); + assertThat(intent.hasExtra(CustomTabsIntent.EXTRA_INITIAL_ACTIVITY_HEIGHT_PX), is(true)); + assertThat(intent.getIntExtra(CustomTabsIntent.EXTRA_ACTIVITY_HEIGHT_RESIZE_BEHAVIOR, CustomTabsIntent.ACTIVITY_HEIGHT_DEFAULT), is(CustomTabsIntent.ACTIVITY_HEIGHT_ADJUSTABLE)); + } + @Test public void shouldSetToolbarCornerRadius() { CustomTabsOptions options = CustomTabsOptions.newBuilder() @@ -370,6 +384,7 @@ public void shouldSetBackgroundInteractionEnabled() { assertThat(intent, is(notNullValue())); // When background interaction is enabled, EXTRA_DISABLE_BACKGROUND_INTERACTION should be present and false assertThat(intent.hasExtra(CustomTabsIntent.EXTRA_DISABLE_BACKGROUND_INTERACTION), is(true)); + assertThat(intent.getBooleanExtra(CustomTabsIntent.EXTRA_DISABLE_BACKGROUND_INTERACTION, true), is(false)); Parcel parcel = Parcel.obtain(); options.writeToParcel(parcel, 0); @@ -377,6 +392,7 @@ public void shouldSetBackgroundInteractionEnabled() { CustomTabsOptions parceledOptions = CustomTabsOptions.CREATOR.createFromParcel(parcel); Intent parceledIntent = parceledOptions.toIntent(context, null); assertThat(parceledIntent.hasExtra(CustomTabsIntent.EXTRA_DISABLE_BACKGROUND_INTERACTION), is(true)); + assertThat(parceledIntent.getBooleanExtra(CustomTabsIntent.EXTRA_DISABLE_BACKGROUND_INTERACTION, true), is(false)); } @Test @@ -451,7 +467,7 @@ public void shouldNotSetPartialOptionsWhenDisabledBrowser() { CustomTabsOptions options = CustomTabsOptions.newBuilder() .withBrowserPicker(browserPicker) - .withDisabledCustomTabsPackages(List.of("com.auth0.browser")) + .withDisabledCustomTabsPackages(Collections.singletonList("com.auth0.browser")) .withInitialHeight(800) .withToolbarCornerRadius(16) .build(); @@ -462,4 +478,4 @@ public void shouldNotSetPartialOptionsWhenDisabledBrowser() { assertThat(intentNoExtras.getExtras(), is(nullValue())); assertEquals(intentNoExtras.getAction(), "android.intent.action.VIEW"); } -} \ No newline at end of file +} From 448b78d67a591309e45715cae901945e3a702b2b Mon Sep 17 00:00:00 2001 From: Subhankar Maiti Date: Fri, 17 Apr 2026 16:46:17 +0530 Subject: [PATCH 23/60] feat: Add documentation for Partial Custom Tabs with bottom sheet and side sheet options --- EXAMPLES.md | 70 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 70 insertions(+) diff --git a/EXAMPLES.md b/EXAMPLES.md index da6c10333..1163d863a 100644 --- a/EXAMPLES.md +++ b/EXAMPLES.md @@ -8,6 +8,7 @@ - [Specify Parameter](#specify-parameter) - [Specify a Custom Authorize URL](#specify-a-custom-authorize-url) - [Customize the Custom Tabs UI](#customize-the-custom-tabs-ui) + - [Partial Custom Tabs (Bottom Sheet and Side Sheet)](#partial-custom-tabs-bottom-sheet-and-side-sheet) - [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) @@ -175,6 +176,75 @@ WebAuthProvider.login(account) ``` +### Partial Custom Tabs (Bottom Sheet and Side Sheet) + +You can present the authentication flow as a **bottom sheet** on compact screens or a **side sheet** on larger screens (e.g., tablets and foldables) instead of a full-screen browser tab. This is configured through `CustomTabsOptions`. + +> **Browser compatibility:** +> - **Bottom sheet** (Partial Custom Tabs) requires **Chrome 107+** (or another Custom Tabs browser that supports the Partial Custom Tabs protocol). +> - **Side sheet** requires **Chrome 120+** (or another browser that supports side-sheet Custom Tabs). +> +> If the user's browser does not meet the minimum version requirement, the authentication flow automatically falls back to a standard full-screen Custom Tab (or a full-screen browser tab if Custom Tabs are unsupported). It is therefore safe to enable these options unconditionally — users on older browsers will simply see the full-screen experience. + +#### Bottom sheet + +```kotlin +val ctOptions = CustomTabsOptions.newBuilder() + .withInitialHeight(700) // initial height in dp + .withResizable(true) // allow the user to drag to resize (default) + .withToolbarCornerRadius(16) // rounded top corners (0–16 dp) + .build() + +WebAuthProvider.login(account) + .withCustomTabsOptions(ctOptions) + .start(this, callback) +``` + +#### Side sheet (with bottom-sheet fallback on narrow screens) + +```kotlin +val ctOptions = CustomTabsOptions.newBuilder() + .withInitialHeight(700) // used when the screen is narrower than the breakpoint + .withInitialWidth(500) // initial side-sheet width in dp + .withSideSheetBreakpoint(840) // screens wider than this render as a side sheet + .build() + +WebAuthProvider.login(account) + .withCustomTabsOptions(ctOptions) + .start(this, callback) +``` + +If `withSideSheetBreakpoint` is not set, the browser's default breakpoint (typically 840 dp in Chrome) applies, so devices narrower than that will continue to render as a bottom sheet or full screen. + +#### Allow interaction with the app behind the partial tab + +By default, the app behind a Partial Custom Tab is non-interactive. Enable pass-through interaction with: + +```kotlin +val ctOptions = CustomTabsOptions.newBuilder() + .withInitialHeight(700) + .withBackgroundInteractionEnabled(true) + .build() +``` + +

+ Using Java + +```java +CustomTabsOptions options = CustomTabsOptions.newBuilder() + .withInitialHeight(700) + .withInitialWidth(500) + .withSideSheetBreakpoint(840) + .withToolbarCornerRadius(16) + .withBackgroundInteractionEnabled(true) + .build(); + +WebAuthProvider.login(account) + .withCustomTabsOptions(options) + .start(MainActivity.this, callback); +``` +
+ ## Changing the Return To URL scheme This configuration will probably match what you've done for the [authentication setup](#a-note-about-app-deep-linking). From a84a32b2727397af0e5c8ddc16927c522caceab9 Mon Sep 17 00:00:00 2001 From: Subhankar Maiti Date: Fri, 17 Apr 2026 16:49:23 +0530 Subject: [PATCH 24/60] feat: Clamp toolbar corner radius in CustomTabsOptions and add tests for boundary conditions --- .../android/provider/CustomTabsOptions.java | 15 ++++++++---- .../provider/CustomTabsOptionsTest.java | 24 +++++++++++++++++++ 2 files changed, 35 insertions(+), 4 deletions(-) diff --git a/auth0/src/main/java/com/auth0/android/provider/CustomTabsOptions.java b/auth0/src/main/java/com/auth0/android/provider/CustomTabsOptions.java index 83a818a94..f487c7f5b 100644 --- a/auth0/src/main/java/com/auth0/android/provider/CustomTabsOptions.java +++ b/auth0/src/main/java/com/auth0/android/provider/CustomTabsOptions.java @@ -324,15 +324,22 @@ public Builder withResizable(boolean resizable) { * Sets the toolbar's top corner radii in dp. Only takes effect when the Custom Tab is * displayed as a bottom sheet (i.e., when {@link #withInitialHeight(int)} is also set). * Pass the size in dp. The underlying - * {@link CustomTabsIntent.Builder#setToolbarCornerRadiusDp(int)} currently accepts - * values in the range {@code 0}–{@code 16} (inclusive) and will throw an - * {@link IllegalArgumentException} for values outside that range. + * {@link CustomTabsIntent.Builder#setToolbarCornerRadiusDp(int)} only accepts values in + * the range {@code 0}–{@code 16} (inclusive); to avoid a runtime crash, values + * outside that range are clamped: negative values are treated as {@code 0} and values + * greater than {@code 16} are capped to {@code 16}. * - * @param cornerRadius the toolbar's top corner radius in dp. + * @param cornerRadius the toolbar's top corner radius in dp. Clamped to {@code [0, 16]}. * @return this same builder instance. */ @NonNull public Builder withToolbarCornerRadius(@Dimension(unit = Dimension.DP) int cornerRadius) { + if (cornerRadius < 0) { + cornerRadius = 0; + } + if (cornerRadius > 16) { + cornerRadius = 16; + } this.toolbarCornerRadius = cornerRadius; return this; } diff --git a/auth0/src/test/java/com/auth0/android/provider/CustomTabsOptionsTest.java b/auth0/src/test/java/com/auth0/android/provider/CustomTabsOptionsTest.java index cf1c3c9a2..a3cd10c02 100644 --- a/auth0/src/test/java/com/auth0/android/provider/CustomTabsOptionsTest.java +++ b/auth0/src/test/java/com/auth0/android/provider/CustomTabsOptionsTest.java @@ -322,6 +322,30 @@ public void shouldSetToolbarCornerRadius() { assertThat(parceledIntent.getIntExtra(CustomTabsIntent.EXTRA_TOOLBAR_CORNER_RADIUS_DP, 0), is(16)); } + @Test + public void shouldClampToolbarCornerRadiusAboveMax() { + CustomTabsOptions options = CustomTabsOptions.newBuilder() + .withInitialHeight(800) + .withToolbarCornerRadius(50) + .build(); + + Intent intent = options.toIntent(context, null); + assertThat(intent.hasExtra(CustomTabsIntent.EXTRA_TOOLBAR_CORNER_RADIUS_DP), is(true)); + assertThat(intent.getIntExtra(CustomTabsIntent.EXTRA_TOOLBAR_CORNER_RADIUS_DP, 0), is(16)); + } + + @Test + public void shouldClampNegativeToolbarCornerRadiusToZero() { + CustomTabsOptions options = CustomTabsOptions.newBuilder() + .withInitialHeight(800) + .withToolbarCornerRadius(-10) + .build(); + + Intent intent = options.toIntent(context, null); + // Clamped to 0; since the toIntent guard is `if (toolbarCornerRadius > 0)`, no extra is set. + assertThat(intent.hasExtra(CustomTabsIntent.EXTRA_TOOLBAR_CORNER_RADIUS_DP), is(false)); + } + // --- Partial Custom Tabs: Side Sheet --- // Note: withInitialWidth accepts dp; converted to px internally (1:1 in Robolectric mdpi). From e3bbb336bd1b6d814a14423b9782ed187ece6c64 Mon Sep 17 00:00:00 2001 From: Prince Mathew <17837162+pmathew92@users.noreply.github.com> Date: Tue, 21 Apr 2026 11:06:41 +0530 Subject: [PATCH 25/60] doc : Added the v4 beta reference in Readme (#959) --- README.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/README.md b/README.md index 8325e7e39..2fdf3d179 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,14 @@ 📚 [Documentation](#documentation) • 🚀 [Getting Started](#getting-started) • 💬 [Feedback](#feedback) +## 🚀 v4 Beta Available + +A new major version [v4.0.0-beta.0](https://github.com/auth0/Auth0.Android/releases/tag/4.0.0-beta.0) of Auth0.Android is now available in beta. It includes breaking changes and improvements over v3. + +We'd love for you to try it out and share your feedback! Please [open an issue](https://github.com/auth0/Auth0.Android/issues/new) if you encounter any problems or have suggestions. + +📚 [Migration Guide](https://github.com/auth0/Auth0.Android/blob/v4_development/V4_MIGRATION_GUIDE.md) 📦 [v4 Changelog](https://github.com/auth0/Auth0.Android/blob/v4_development/CHANGELOG.md) + ## Documentation - [Quickstart](https://auth0.com/docs/quickstart/native/android/interactive) - [Sample App](https://github.com/auth0-samples/auth0-android-sample/tree/master/00-Login-Kt) From 6a52e68cd3961800228e813de12fb00a866c6300 Mon Sep 17 00:00:00 2001 From: Subhankar Maiti Date: Mon, 27 Apr 2026 15:51:17 +0530 Subject: [PATCH 26/60] Release 3.16.0 --- .version | 2 +- CHANGELOG.md | 6 ++++++ README.md | 2 +- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/.version b/.version index 4bcbd94b5..7ccd8d446 100644 --- a/.version +++ b/.version @@ -1 +1 @@ -3.15.0 \ No newline at end of file +3.16.0 \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index da3c6a5e2..da08e3dc2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Change Log +## [3.16.0](https://github.com/auth0/Auth0.Android/tree/3.16.0) (2026-04-27) +[Full Changelog](https://github.com/auth0/Auth0.Android/compare/3.15.0...3.16.0) + +**Added** +- feat : Add Partial Custom Tabs support (bottom sheet & side sheet) [\#954](https://github.com/auth0/Auth0.Android/pull/954) ([subhankarmaiti](https://github.com/subhankarmaiti)) + ## [3.15.0](https://github.com/auth0/Auth0.Android/tree/3.15.0) (2026-04-15) [Full Changelog](https://github.com/auth0/Auth0.Android/compare/3.14.0...3.15.0) diff --git a/README.md b/README.md index 2fdf3d179..c068786bf 100644 --- a/README.md +++ b/README.md @@ -60,7 +60,7 @@ To install Auth0.Android with [Gradle](https://gradle.org/), simply add the foll ```gradle dependencies { - implementation 'com.auth0.android:auth0:3.15.0' + implementation 'com.auth0.android:auth0:3.16.0' } ``` From e8a2257f5a9d96e64f54a07a7a77518016a87712 Mon Sep 17 00:00:00 2001 From: Prince Mathew <17837162+pmathew92@users.noreply.github.com> Date: Tue, 5 May 2026 11:55:15 +0530 Subject: [PATCH 27/60] chore: migrate RL scanner to shared devsecops-tooling action (#963) --- .github/actions/rl-scanner/action.yml | 73 ------------------------- .github/workflows/release.yml | 41 ++++++++++---- .github/workflows/rl-scanner.yml | 78 --------------------------- 3 files changed, 30 insertions(+), 162 deletions(-) delete mode 100644 .github/actions/rl-scanner/action.yml delete mode 100644 .github/workflows/rl-scanner.yml diff --git a/.github/actions/rl-scanner/action.yml b/.github/actions/rl-scanner/action.yml deleted file mode 100644 index ca6a2c80f..000000000 --- a/.github/actions/rl-scanner/action.yml +++ /dev/null @@ -1,73 +0,0 @@ - -name: 'RL-Security-Scanner' -description: 'Runs the Reversing Labs scanner on a specified artifact.' -inputs: - artifact-path: - description: 'Path to the artifact to be scanned.' - required: true - version: - description: 'Version of the artifact.' - required: true - - -runs: - using: 'composite' - steps: - - name: Set up python - uses: actions/setup-python@v4 - with: - python-version: '3.10' - - - name: Install Python dependencies - shell: bash - run: | - pip install boto3 requests - - - name: Configure AWS credentials - uses: aws-actions/configure-aws-credentials@v1 - with: - role-to-assume: ${{ env.PRODSEC_TOOLS_ARN }} - aws-region: 'us-east-1' - mask-aws-account-id: true - - - name: Install RL Wrapper - shell: bash - run: | - pip install rl-wrapper --index-url "https://${{ env.PRODSEC_TOOLS_USER }}:${{ env.PRODSEC_TOOLS_TOKEN }}@a0us.jfrog.io/artifactory/api/pypi/python/simple" - - - name: Run RL Scanner - shell: bash - env: - RLSECURE_LICENSE: ${{ env.RLSECURE_LICENSE }} - RLSECURE_SITE_KEY: ${{ env.RLSECURE_SITE_KEY }} - SIGNAL_HANDLER_TOKEN: ${{ env.SIGNAL_HANDLER_TOKEN }} - PYTHONUNBUFFERED: 1 - run: | - if [ ! -f "${{ inputs.artifact-path }}" ]; then - echo "Artifact not found: ${{ inputs.artifact-path }}" - exit 1 - fi - - rl-wrapper \ - --artifact "${{ inputs.artifact-path }}" \ - --version "${{ inputs.version }}" \ - --name "${{ github.event.repository.name }}" \ - --repository "${{ github.repository }}" \ - --commit "${{ github.sha }}" \ - --build-env "github_actions" \ - --suppress-output - - # Check the outcome of the scanner - if [ $? -ne 0 ]; then - echo "RL Scanner failed." - echo "scan-status=failed" >> $GITHUB_ENV - exit 1 - else - echo "RL Scanner passed." - echo "scan-status=success" >> $GITHUB_ENV - fi - -outputs: - scan-status: - description: 'The outcome of the scan process.' - value: ${{ env.scan-status }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d74a96c02..883300f1b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -16,17 +16,36 @@ permissions: jobs: rl-scanner: - uses: ./.github/workflows/rl-scanner.yml - with: - java-version: 8.0.402-zulu - artifact-name: 'auth0-release.aar' - secrets: - RLSECURE_LICENSE: ${{ secrets.RLSECURE_LICENSE }} - RLSECURE_SITE_KEY: ${{ secrets.RLSECURE_SITE_KEY }} - SIGNAL_HANDLER_TOKEN: ${{ secrets.SIGNAL_HANDLER_TOKEN }} - PRODSEC_TOOLS_USER: ${{ secrets.PRODSEC_TOOLS_USER }} - PRODSEC_TOOLS_TOKEN: ${{ secrets.PRODSEC_TOOLS_TOKEN }} - PRODSEC_TOOLS_ARN: ${{ secrets.PRODSEC_TOOLS_ARN }} + if: github.event_name == 'workflow_dispatch' || (github.event_name == 'pull_request' && github.event.pull_request.merged && startsWith(github.event.pull_request.head.ref, 'release/')) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Build with gradle + run: ./gradlew :auth0:assembleRelease + + - name: Get version + id: get_version + run: | + version=$(cat .version) + echo "version=$version" >> $GITHUB_OUTPUT + + - name: Run RL Scanner + uses: auth0/devsecops-tooling/.github/actions/rl-scan@main + with: + artifact-name: "auth0-android" + artifact-path: "${{ github.workspace }}/auth0/build/outputs/aar/auth0-release.aar" + version: ${{ steps.get_version.outputs.version }} + RLSECURE_LICENSE: ${{ secrets.RLSECURE_LICENSE }} + RLSECURE_SITE_KEY: ${{ secrets.RLSECURE_SITE_KEY }} + SIGNAL_HANDLER_TOKEN: ${{ secrets.SIGNAL_HANDLER_TOKEN }} + SIGNAL_HANDLER_DOMAIN: ${{ secrets.SIGNAL_HANDLER_DOMAIN }} + PRODSEC_TOOLS_ARN: ${{ secrets.PRODSEC_TOOLS_ARN }} + PRODSEC_TOOLS_USER: ${{ secrets.PRODSEC_TOOLS_USER }} + PRODSEC_TOOLS_TOKEN: ${{ secrets.PRODSEC_TOOLS_TOKEN }} + PRODSEC_PYTHON_TOOLS_REPO: ${{ secrets.PRODSEC_PYTHON_TOOLS_REPO }} release: uses: ./.github/workflows/java-release.yml diff --git a/.github/workflows/rl-scanner.yml b/.github/workflows/rl-scanner.yml deleted file mode 100644 index bc3dca514..000000000 --- a/.github/workflows/rl-scanner.yml +++ /dev/null @@ -1,78 +0,0 @@ -name: RL-Security-Scanner -run-name: rl-security-scanner - - -on: - workflow_call: - inputs: - java-version: - required: true - type: string - artifact-name: - required: true - type: string - secrets: - RLSECURE_LICENSE: - required: true - RLSECURE_SITE_KEY: - required: true - SIGNAL_HANDLER_TOKEN: - required: true - PRODSEC_TOOLS_USER: - required: true - PRODSEC_TOOLS_TOKEN: - required: true - PRODSEC_TOOLS_ARN: - required: true - - -jobs: - rl-scanner: - name: Run Reversing Labs Scanner - if: github.event_name == 'workflow_dispatch' || (github.event_name == 'pull_request' && github.event.pull_request.merged && startsWith(github.event.pull_request.head.ref, 'release/')) - runs-on: ubuntu-latest - outputs: - scan-status: ${{ steps.rl-scan-conclusion.outcome }} - - - steps: - - name: Checkout code - uses: actions/checkout@v6 - with: - fetch-depth: 0 - - - name: Build with gradle - shell: bash - run: ./gradlew :auth0:assembleRelease - - - name: Get Artifact Version - id: get_version - run: | - version=$(cat .version) - echo "version=$version" >> $GITHUB_OUTPUT - - - name: List build contents - run: ls -la auth0/build/outputs/aar - - - name: Output build artifact - id: output_build_artifact - run: | - echo "scanfile=$(pwd)/auth0/build/outputs/aar/auth0-release-${{ steps.get_version.outputs.version }}.aar" >> $GITHUB_OUTPUT - - - - name: Run Reversing Labs Scanner - id: rl-scan-conclusion - uses: ./.github/actions/rl-scanner - with: - artifact-path: "$(pwd)/auth0/build/outputs/aar/${{ inputs.artifact-name }}" - version: "${{ steps.get_version.outputs.version }}" - env: - RLSECURE_LICENSE: ${{ secrets.RLSECURE_LICENSE }} - RLSECURE_SITE_KEY: ${{ secrets.RLSECURE_SITE_KEY }} - SIGNAL_HANDLER_TOKEN: ${{ secrets.SIGNAL_HANDLER_TOKEN }} - PRODSEC_TOOLS_USER: ${{ secrets.PRODSEC_TOOLS_USER }} - PRODSEC_TOOLS_TOKEN: ${{ secrets.PRODSEC_TOOLS_TOKEN }} - PRODSEC_TOOLS_ARN: ${{ secrets.PRODSEC_TOOLS_ARN }} - - - name: Output scan result - run: echo "scan-status=${{ steps.rl-scan-conclusion.outcome }}" >> $GITHUB_ENV \ No newline at end of file From e9914b7dd28b06d98e8c806a4cb04a1f8241ff51 Mon Sep 17 00:00:00 2001 From: Prince Mathew <17837162+pmathew92@users.noreply.github.com> Date: Fri, 15 May 2026 12:52:25 +0530 Subject: [PATCH 28/60] feat :Add partial support for PAR auth flow (#967) --- EXAMPLES.md | 70 ++++++ .../android/provider/AuthorizeResultParser.kt | 52 ++++ .../auth0/android/provider/PARCodeManager.kt | 88 +++++++ .../android/provider/PARCodeManagerState.kt | 87 +++++++ .../com/auth0/android/provider/PARUtils.kt | 40 +++ .../auth0/android/provider/WebAuthProvider.kt | 187 +++++++++++++- .../auth0/android/result/AuthorizationCode.kt | 27 ++ .../android/provider/PARCodeManagerTest.kt | 235 ++++++++++++++++++ .../android/provider/WebAuthProviderTest.kt | 84 +++++++ 9 files changed, 866 insertions(+), 4 deletions(-) create mode 100644 auth0/src/main/java/com/auth0/android/provider/AuthorizeResultParser.kt create mode 100644 auth0/src/main/java/com/auth0/android/provider/PARCodeManager.kt create mode 100644 auth0/src/main/java/com/auth0/android/provider/PARCodeManagerState.kt create mode 100644 auth0/src/main/java/com/auth0/android/provider/PARUtils.kt create mode 100644 auth0/src/main/java/com/auth0/android/result/AuthorizationCode.kt create mode 100644 auth0/src/test/java/com/auth0/android/provider/PARCodeManagerTest.kt diff --git a/EXAMPLES.md b/EXAMPLES.md index 1163d863a..1fc8ca1ee 100644 --- a/EXAMPLES.md +++ b/EXAMPLES.md @@ -31,6 +31,7 @@ - [Get user information](#get-user-information) - [Custom Token Exchange](#custom-token-exchange) - [Native to Web SSO login](#native-to-web-sso-login) + - [Pushed Authorization Requests (PAR)](#pushed-authorization-requests-par) - [DPoP](#dpop-1) - [My Account API](#my-account-api) - [Enroll a new passkey](#enroll-a-new-passkey) @@ -1691,6 +1692,75 @@ authentication ``` +### Pushed Authorization Requests (PAR) + +This feature handles the browser authorization step of a [PAR (RFC 9126)](https://www.rfc-editor.org/rfc/rfc9126.html) flow. It opens the `/authorize` endpoint with a `request_uri` obtained from your backend's PAR endpoint call, and returns the authorization code for your backend to exchange for tokens. + +> [!IMPORTANT] +> Auth0 only supports PAR for **confidential clients**. Since mobile apps are public clients, the `/oauth/par` and `/oauth/token` calls must be made by your backend (BFF - Backend for Frontend). The SDK only handles opening the browser with the `request_uri` and returning the resulting authorization code. +> +> Your Auth0 application configured in the SDK should use the **same client_id** as the one your backend uses when calling the `/oauth/par` endpoint. + +```kotlin +WebAuthProvider.authorizeWithRequestUri(account) + .start(context, requestUri, object : Callback { + override fun onSuccess(result: AuthorizationCode) { + // Send result.code to your BFF for token exchange + // Validate result.state against the state your BFF used in the PAR request + } + + override fun onFailure(exception: AuthenticationException) { + // Handle error + } + }) +``` + +> [!NOTE] +> The SDK does not validate the `state` parameter. The `state` is generated by your BFF when calling `/oauth/par` and returned as-is in `AuthorizationCode.state`. Your app or BFF **must** validate that the returned `state` matches the original value to prevent CSRF attacks. + +
+ Using coroutines + +```kotlin +try { + val authCode = WebAuthProvider.authorizeWithRequestUri(account) + .await(context, requestUri) + // Send authCode.code to your BFF for token exchange + // Validate authCode.state against the state your BFF used in the PAR request +} catch (e: AuthenticationException) { + e.printStackTrace() +} +``` +
+ +
+ Using Java + +```java +WebAuthProvider.authorizeWithRequestUri(account) + .start(context, requestUri, new Callback() { + @Override + public void onSuccess(@NonNull AuthorizationCode result) { + // Send result.getCode() to your BFF for token exchange + // Validate result.getState() against the state your BFF used in the PAR request + } + + @Override + public void onFailure(@NonNull AuthenticationException exception) { + // Handle error + } + }); +``` +
+ +You can also pass a session transfer token to enable web SSO by transferring an existing native session to the browser: + +```kotlin +WebAuthProvider.authorizeWithRequestUri(account) + .withSessionTransferToken(sessionTransferToken) + .await(context, requestUri) +``` + ## DPoP [DPoP](https://www.rfc-editor.org/rfc/rfc9449.html) (Demonstrating Proof of Possession) is an application-level mechanism for sender-constraining OAuth 2.0 access and refresh tokens by proving that the app is in possession of a certain private key. You can enable it by calling the `useDPoP(context: Context)` method. This ensures that DPoP proofs are generated for requests made through the AuthenticationAPI client. diff --git a/auth0/src/main/java/com/auth0/android/provider/AuthorizeResultParser.kt b/auth0/src/main/java/com/auth0/android/provider/AuthorizeResultParser.kt new file mode 100644 index 000000000..f271588a3 --- /dev/null +++ b/auth0/src/main/java/com/auth0/android/provider/AuthorizeResultParser.kt @@ -0,0 +1,52 @@ +package com.auth0.android.provider + +import com.auth0.android.authentication.AuthenticationException + +/** + * Parses the result from an authorization redirect callback. + */ +internal object AuthorizeResultParser { + + sealed class CodeResult { + data class Success(val code: String, val state: String?) : CodeResult() + data class Error(val exception: AuthenticationException) : CodeResult() + object Canceled : CodeResult() + object Invalid : CodeResult() + } + + private const val KEY_CODE = "code" + private const val KEY_STATE = "state" + private const val KEY_ERROR = "error" + private const val KEY_ERROR_DESCRIPTION = "error_description" + + fun parse(result: AuthorizeResult, requestCode: Int): CodeResult { + if (!result.isValid(requestCode)) { + return CodeResult.Invalid + } + + if (result.isCanceled) { + return CodeResult.Canceled + } + + val values = CallbackHelper.getValuesFromUri(result.intentData) + if (values.isEmpty()) { + return CodeResult.Invalid + } + + val error = values[KEY_ERROR] + if (error != null) { + val description = values[KEY_ERROR_DESCRIPTION] ?: error + return CodeResult.Error(AuthenticationException(error, description)) + } + + val code = values[KEY_CODE] + ?: return CodeResult.Error( + AuthenticationException( + "access_denied", + "No authorization code was received in the callback." + ) + ) + + return CodeResult.Success(code = code, state = values[KEY_STATE]) + } +} diff --git a/auth0/src/main/java/com/auth0/android/provider/PARCodeManager.kt b/auth0/src/main/java/com/auth0/android/provider/PARCodeManager.kt new file mode 100644 index 000000000..190e81502 --- /dev/null +++ b/auth0/src/main/java/com/auth0/android/provider/PARCodeManager.kt @@ -0,0 +1,88 @@ +package com.auth0.android.provider + +import android.content.Context +import com.auth0.android.Auth0 +import com.auth0.android.authentication.AuthenticationException +import com.auth0.android.callback.Callback +import com.auth0.android.result.AuthorizationCode + +/** + * Manager for handling PAR (Pushed Authorization Request) code-only flows. + * This manager handles opening the authorize URL with a request_uri and + * returns the authorization code to the caller for BFF token exchange. + */ +internal class PARCodeManager( + private val account: Auth0, + private val callback: Callback, + private val requestUri: String, + private val sessionTransferToken: String? = null, + private val ctOptions: CustomTabsOptions = CustomTabsOptions.newBuilder().build() +) : ResumableManager() { + + private var requestCode = 0 + + fun startAuthentication(context: Context, requestCode: Int) { + this.requestCode = requestCode + val additionalParams = buildMap { + sessionTransferToken?.let { put("session_transfer_token", it) } + } + val uri = PARUtils.buildAuthorizeUri(account, requestUri, additionalParams) + AuthenticationActivity.authenticateUsingBrowser(context, uri, false, ctOptions) + } + + override fun resume(result: AuthorizeResult): Boolean { + return when (val parsed = AuthorizeResultParser.parse(result, requestCode)) { + is AuthorizeResultParser.CodeResult.Success -> { + callback.onSuccess(AuthorizationCode(parsed.code, parsed.state)) + true + } + is AuthorizeResultParser.CodeResult.Error -> { + callback.onFailure(parsed.exception) + true + } + is AuthorizeResultParser.CodeResult.Canceled -> { + callback.onFailure( + AuthenticationException( + AuthenticationException.ERROR_VALUE_AUTHENTICATION_CANCELED, + "The user closed the browser app and the authentication was canceled." + ) + ) + true + } + AuthorizeResultParser.CodeResult.Invalid -> false + } + } + + override fun failure(exception: AuthenticationException) { + callback.onFailure(exception) + } + + internal fun toState(): PARCodeManagerState { + return PARCodeManagerState( + auth0 = account, + requestCode = requestCode, + requestUri = requestUri, + sessionTransferToken = sessionTransferToken, + ctOptions = ctOptions + ) + } + + internal companion object { + private val TAG = PARCodeManager::class.java.simpleName + + fun fromState( + state: PARCodeManagerState, + callback: Callback + ): PARCodeManager { + val manager = PARCodeManager( + account = state.auth0, + callback = callback, + requestUri = state.requestUri, + sessionTransferToken = state.sessionTransferToken, + ctOptions = state.ctOptions + ) + manager.requestCode = state.requestCode + return manager + } + } +} diff --git a/auth0/src/main/java/com/auth0/android/provider/PARCodeManagerState.kt b/auth0/src/main/java/com/auth0/android/provider/PARCodeManagerState.kt new file mode 100644 index 000000000..a1ce7c4af --- /dev/null +++ b/auth0/src/main/java/com/auth0/android/provider/PARCodeManagerState.kt @@ -0,0 +1,87 @@ +package com.auth0.android.provider + +import android.os.Parcel +import android.os.Parcelable +import android.util.Base64 +import androidx.core.os.ParcelCompat +import com.auth0.android.Auth0 +import com.auth0.android.request.internal.GsonProvider +import com.google.gson.Gson + +internal data class PARCodeManagerState( + val auth0: Auth0, + val requestCode: Int, + val requestUri: String, + val sessionTransferToken: String?, + val ctOptions: CustomTabsOptions +) { + + private class PARCodeManagerJson( + val auth0ClientId: String, + val auth0DomainUrl: String, + val auth0ConfigurationUrl: String?, + val requestCode: Int, + val requestUri: String, + val sessionTransferToken: String?, + val ctOptions: String + ) + + fun serializeToJson(gson: Gson = GsonProvider.gson): String { + val parcel = Parcel.obtain() + try { + parcel.writeParcelable(ctOptions, Parcelable.PARCELABLE_WRITE_RETURN_VALUE) + val ctOptionsEncoded = Base64.encodeToString(parcel.marshall(), Base64.DEFAULT) + + val json = PARCodeManagerJson( + auth0ClientId = auth0.clientId, + auth0DomainUrl = auth0.domain, + auth0ConfigurationUrl = auth0.configurationDomain, + requestCode = requestCode, + requestUri = requestUri, + sessionTransferToken = sessionTransferToken, + ctOptions = ctOptionsEncoded + ) + return gson.toJson(json) + } finally { + parcel.recycle() + } + } + + companion object { + fun deserializeState( + json: String, + gson: Gson = GsonProvider.gson + ): PARCodeManagerState { + val parcel = Parcel.obtain() + try { + val parsed = gson.fromJson(json, PARCodeManagerJson::class.java) + + val decodedCtOptionsBytes = Base64.decode(parsed.ctOptions, Base64.DEFAULT) + parcel.unmarshall(decodedCtOptionsBytes, 0, decodedCtOptionsBytes.size) + parcel.setDataPosition(0) + + val customTabsOptions = ParcelCompat.readParcelable( + parcel, + CustomTabsOptions::class.java.classLoader, + CustomTabsOptions::class.java + ) ?: error("Couldn't deserialize CustomTabsOptions from Parcel") + + val auth0 = Auth0.getInstance( + clientId = parsed.auth0ClientId, + domain = parsed.auth0DomainUrl, + configurationDomain = parsed.auth0ConfigurationUrl + ) + + return PARCodeManagerState( + auth0 = auth0, + requestCode = parsed.requestCode, + requestUri = parsed.requestUri, + sessionTransferToken = parsed.sessionTransferToken, + ctOptions = customTabsOptions + ) + } finally { + parcel.recycle() + } + } + } +} diff --git a/auth0/src/main/java/com/auth0/android/provider/PARUtils.kt b/auth0/src/main/java/com/auth0/android/provider/PARUtils.kt new file mode 100644 index 000000000..c2bc48234 --- /dev/null +++ b/auth0/src/main/java/com/auth0/android/provider/PARUtils.kt @@ -0,0 +1,40 @@ +package com.auth0.android.provider + +import android.net.Uri +import com.auth0.android.Auth0 +import com.auth0.android.authentication.AuthenticationException +import androidx.core.net.toUri + +/** + * Shared utilities for PAR (Pushed Authorization Request) flows. + */ +internal object PARUtils { + + internal const val REQUEST_URI_PREFIX = "urn:ietf:params:oauth:request_uri:" + + /** + * Validates that the request_uri conforms to the expected format. + * @return true if valid, false otherwise. + */ + fun isValidRequestUri(requestUri: String): Boolean { + return requestUri.startsWith(REQUEST_URI_PREFIX) + } + + /** + * Builds a minimal /authorize URI for PAR flows containing only client_id and request_uri, + * plus any additional query parameters. + */ + fun buildAuthorizeUri( + account: Auth0, + requestUri: String, + additionalParameters: Map = emptyMap() + ): Uri { + val builder = account.authorizeUrl.toUri().buildUpon() + .appendQueryParameter("client_id", account.clientId) + .appendQueryParameter("request_uri", requestUri) + for ((key, value) in additionalParameters) { + builder.appendQueryParameter(key, value) + } + return builder.build() + } +} diff --git a/auth0/src/main/java/com/auth0/android/provider/WebAuthProvider.kt b/auth0/src/main/java/com/auth0/android/provider/WebAuthProvider.kt index 955eec9c5..6d647a432 100644 --- a/auth0/src/main/java/com/auth0/android/provider/WebAuthProvider.kt +++ b/auth0/src/main/java/com/auth0/android/provider/WebAuthProvider.kt @@ -11,6 +11,7 @@ import com.auth0.android.authentication.AuthenticationException import com.auth0.android.callback.Callback import com.auth0.android.dpop.DPoP import com.auth0.android.dpop.SenderConstraining +import com.auth0.android.result.AuthorizationCode import com.auth0.android.result.Credentials import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.suspendCancellableCoroutine @@ -30,9 +31,12 @@ import kotlin.coroutines.resumeWithException public object WebAuthProvider : SenderConstraining { private val TAG: String? = WebAuthProvider::class.simpleName private const val KEY_BUNDLE_OAUTH_MANAGER_STATE = "oauth_manager_state" + private const val KEY_BUNDLE_PAR_MANAGER_STATE = "par_manager_state" + private const val AUTH_REQUEST_CODE = 110 private var dPoP : DPoP? = null private val callbacks = CopyOnWriteArraySet>() + private val parCallbacks = CopyOnWriteArraySet>() @JvmStatic @get:VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) @@ -79,6 +83,22 @@ public object WebAuthProvider : SenderConstraining { return Builder(account) } + /** + * Initialize the WebAuthProvider instance for request_uri based authorization flows (PAR). + * Use this when your backend (BFF) has already called the /oauth/par endpoint and you need to + * complete the authorization by opening /authorize with the request_uri. + * + * The SDK only handles opening the browser and returning the authorization code. + * Token exchange must be performed by your backend server which holds the client_secret. + * + * @param account to use for authentication + * @return a new PARBuilder instance to customize. + */ + @JvmStatic + public fun authorizeWithRequestUri(account: Auth0): PARBuilder { + return PARBuilder(account) + } + /** * Finishes the authentication or log out flow by passing the data received in the activity's onNewIntent() callback. * The final result will be delivered to the callback specified when calling start(). @@ -116,14 +136,18 @@ public object WebAuthProvider : SenderConstraining { if (manager is OAuthManager) { val managerState = manager.toState() bundle.putString(KEY_BUNDLE_OAUTH_MANAGER_STATE, managerState.serializeToJson()) + } else if (manager is PARCodeManager) { + val managerState = manager.toState() + bundle.putString(KEY_BUNDLE_PAR_MANAGER_STATE, managerState.serializeToJson()) } } internal fun onRestoreInstanceState(bundle: Bundle) { if (managerInstance == null) { - val stateJson = bundle.getString(KEY_BUNDLE_OAUTH_MANAGER_STATE).orEmpty() - if (stateJson.isNotBlank()) { - val state = OAuthManagerState.deserializeState(stateJson) + val oauthStateJson = bundle.getString(KEY_BUNDLE_OAUTH_MANAGER_STATE).orEmpty() + val parStateJson = bundle.getString(KEY_BUNDLE_PAR_MANAGER_STATE).orEmpty() + if (oauthStateJson.isNotBlank()) { + val state = OAuthManagerState.deserializeState(oauthStateJson) managerInstance = OAuthManager.fromState( state, object : Callback { @@ -140,6 +164,24 @@ public object WebAuthProvider : SenderConstraining { } } ) + } else if (parStateJson.isNotBlank()) { + val state = PARCodeManagerState.deserializeState(parStateJson) + managerInstance = PARCodeManager.fromState( + state, + object : Callback { + override fun onSuccess(result: AuthorizationCode) { + for (callback in parCallbacks) { + callback.onSuccess(result) + } + } + + override fun onFailure(error: AuthenticationException) { + for (callback in parCallbacks) { + callback.onFailure(error) + } + } + } + ) } } } @@ -604,7 +646,7 @@ public object WebAuthProvider : SenderConstraining { account.getDomainUrl() ) } - manager.startAuthentication(context, redirectUri!!, 110) + manager.startAuthentication(context, redirectUri!!, AUTH_REQUEST_CODE) } /** @@ -647,4 +689,141 @@ public object WebAuthProvider : SenderConstraining { private const val KEY_CONNECTION_SCOPE = "connection_scope" } } + + /** + * Builder for PAR (Pushed Authorization Request) code-only authentication flows. + * + * Use this builder when your backend (BFF) has already called the PAR endpoint + * and you need to complete the authorization flow by opening the authorize URL + * with the request_uri. + * + * Example usage: + * ```kotlin + * val authCode = WebAuthProvider.authorizeWithRequestUri(account) + * .await(context, requestUri) + * // Send authCode.code to your BFF for token exchange + * // IMPORTANT: Validate authCode.state against the state your BFF + * // used in the PAR request to prevent CSRF attacks. + * ``` + */ + public class PARBuilder internal constructor(private val account: Auth0) { + private var ctOptions: CustomTabsOptions = CustomTabsOptions.newBuilder().build() + private var sessionTransferToken: String? = null + + /** + * When using a Custom Tabs compatible Browser, apply these customization options. + * + * @param options the Custom Tabs customization options + * @return the current builder instance + */ + public fun withCustomTabsOptions(options: CustomTabsOptions): PARBuilder { + ctOptions = options + return this + } + + /** + * Provide a session transfer token to be passed as a query parameter to the /authorize endpoint. + * This enables web single sign-on by transferring an existing session to the browser. + * + * @param token the session transfer token obtained from [AuthenticationAPIClient.ssoExchange] + * @return the current builder instance + */ + public fun withSessionTransferToken(token: String): PARBuilder { + this.sessionTransferToken = token + return this + } + + /** + * Start the PAR authorization flow using a request_uri from a PAR response. + * Opens the browser with the authorize URL and returns the authorization code + * for the app to exchange via BFF. + * + * @param context An Activity context to run the authentication. + * @param requestUri The request_uri obtained from the PAR endpoint (must start with "urn:ietf:params:oauth:request_uri:") + * @param callback Callback with authorization code result + */ + public fun start( + context: Context, + requestUri: String, + callback: Callback + ) { + resetManagerInstance() + + if (!PARUtils.isValidRequestUri(requestUri)) { + val ex = AuthenticationException( + "a0.invalid_request_uri", + "The request_uri must start with \"${PARUtils.REQUEST_URI_PREFIX}\"." + ) + callback.onFailure(ex) + return + } + + if (!ctOptions.hasCompatibleBrowser(context.packageManager)) { + val ex = AuthenticationException( + "a0.browser_not_available", + "No compatible Browser application is installed." + ) + callback.onFailure(ex) + return + } + + val manager = PARCodeManager( + account = account, + callback = callback, + requestUri = requestUri, + sessionTransferToken = sessionTransferToken, + ctOptions = ctOptions + ) + + managerInstance = manager + manager.startAuthentication(context, AUTH_REQUEST_CODE) + } + + /** + * Start the PAR authorization flow using a request_uri from a PAR response. + * Opens the browser with the authorize URL and returns the authorization code + * for the app to exchange via BFF. + * + * @param context An Activity context to run the authentication. + * @param requestUri The request_uri obtained from the PAR endpoint (must start with "urn:ietf:params:oauth:request_uri:") + * @return AuthorizationCode containing the authorization code + * @throws AuthenticationException if authentication fails or the request_uri is invalid + */ + @JvmSynthetic + @Throws(AuthenticationException::class) + public suspend fun await( + context: Context, + requestUri: String + ): AuthorizationCode { + return await(context, requestUri, Dispatchers.Main.immediate) + } + + /** + * Used internally so that [CoroutineContext] can be injected for testing purpose + */ + internal suspend fun await( + context: Context, + requestUri: String, + coroutineContext: CoroutineContext + ): AuthorizationCode { + return withContext(coroutineContext) { + suspendCancellableCoroutine { continuation -> + start( + context, + requestUri, + object : Callback { + override fun onSuccess(result: AuthorizationCode) { + continuation.resume(result) + } + + override fun onFailure(error: AuthenticationException) { + continuation.resumeWithException(error) + } + } + ) + } + } + } + + } } diff --git a/auth0/src/main/java/com/auth0/android/result/AuthorizationCode.kt b/auth0/src/main/java/com/auth0/android/result/AuthorizationCode.kt new file mode 100644 index 000000000..071d8420f --- /dev/null +++ b/auth0/src/main/java/com/auth0/android/result/AuthorizationCode.kt @@ -0,0 +1,27 @@ +package com.auth0.android.result + +/** + * Result returned when the SDK completes a PAR (Pushed Authorization Request) flow. + * Contains the authorization code that should be sent to your backend (BFF) for token exchange. + * + * **Important:** The SDK does not validate the [state] parameter. Your app or BFF must validate + * that the returned [state] matches the value originally used in the PAR request to prevent + * CSRF attacks. + * + * @property code The authorization code received from Auth0. + * @property state The optional state parameter received from Auth0, if present. + */ +public data class AuthorizationCode( + /** + * The authorization code received from Auth0. + * This code should be sent to your BFF for token exchange. + */ + public val code: String, + + /** + * The optional state parameter received from Auth0. + * Your app or BFF must validate this against the original state used in the + * PAR request to prevent CSRF attacks. + */ + public val state: String? = null +) diff --git a/auth0/src/test/java/com/auth0/android/provider/PARCodeManagerTest.kt b/auth0/src/test/java/com/auth0/android/provider/PARCodeManagerTest.kt new file mode 100644 index 000000000..f1094e287 --- /dev/null +++ b/auth0/src/test/java/com/auth0/android/provider/PARCodeManagerTest.kt @@ -0,0 +1,235 @@ +package com.auth0.android.provider + +import android.app.Activity +import android.content.Intent +import android.net.Uri +import com.auth0.android.Auth0 +import com.auth0.android.authentication.AuthenticationException +import com.auth0.android.callback.Callback +import com.auth0.android.provider.WebAuthProvider.authorizeWithRequestUri +import com.auth0.android.provider.WebAuthProvider.resume +import com.auth0.android.request.internal.ThreadSwitcherShadow +import com.auth0.android.result.AuthorizationCode +import com.nhaarman.mockitokotlin2.* +import org.hamcrest.CoreMatchers.`is` +import org.hamcrest.CoreMatchers.notNullValue +import org.hamcrest.MatcherAssert.assertThat +import org.junit.Assert +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.ArgumentMatchers +import org.mockito.Mock +import org.mockito.Mockito +import org.mockito.MockitoAnnotations +import org.robolectric.Robolectric +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(shadows = [ThreadSwitcherShadow::class]) +public class PARCodeManagerTest { + + @Mock + private lateinit var callback: Callback + + private lateinit var activity: Activity + private lateinit var account: Auth0 + + private val authCodeCaptor: KArgumentCaptor = argumentCaptor() + private val authExceptionCaptor: KArgumentCaptor = argumentCaptor() + private val intentCaptor: KArgumentCaptor = argumentCaptor() + + private companion object { + private const val DOMAIN = "samples.auth0.com" + private const val CLIENT_ID = "test-client-id" + private const val REQUEST_URI = "urn:ietf:params:oauth:request_uri:6esc_11ACC5bwc014ltc14eY22c" + private const val AUTH_CODE = "test-authorization-code" + private const val SESSION_TRANSFER_TOKEN = "test-session-transfer-token" + } + + @Before + public fun setUp() { + MockitoAnnotations.openMocks(this) + activity = Mockito.spy(Robolectric.buildActivity(Activity::class.java).get()) + account = Auth0.getInstance(CLIENT_ID, DOMAIN) + + Mockito.doReturn(false).`when`(activity).bindService( + any(), + any(), + ArgumentMatchers.anyInt() + ) + BrowserPickerTest.setupBrowserContext( + activity, + listOf("com.auth0.browser"), + null, + null + ) + } + + @Test + public fun shouldStartPARFlowWithCorrectAuthorizeUri() { + authorizeWithRequestUri(account) + .start(activity, REQUEST_URI, callback) + + Assert.assertNotNull(WebAuthProvider.managerInstance) + + verify(activity).startActivity(intentCaptor.capture()) + val uri = intentCaptor.firstValue.getParcelableExtra(AuthenticationActivity.EXTRA_AUTHORIZE_URI) + + assertThat(uri, `is`(notNullValue())) + assertThat(uri?.scheme, `is`("https")) + assertThat(uri?.host, `is`(DOMAIN)) + assertThat(uri?.path, `is`("/authorize")) + assertThat(uri?.getQueryParameter("client_id"), `is`(CLIENT_ID)) + assertThat(uri?.getQueryParameter("request_uri"), `is`(REQUEST_URI)) + } + + @Test + public fun shouldResumeWithValidCode() { + authorizeWithRequestUri(account) + .start(activity, REQUEST_URI, callback) + + verify(activity).startActivity(intentCaptor.capture()) + + val intent = createAuthIntent("code=$AUTH_CODE") + + Assert.assertTrue(resume(intent)) + + verify(callback).onSuccess(authCodeCaptor.capture()) + val authCode = authCodeCaptor.firstValue + assertThat(authCode, `is`(notNullValue())) + assertThat(authCode.code, `is`(AUTH_CODE)) + } + + @Test + public fun shouldResumeWithCodeAndStateFromRedirect() { + authorizeWithRequestUri(account) + .start(activity, REQUEST_URI, callback) + + verify(activity).startActivity(intentCaptor.capture()) + + val intent = createAuthIntent("code=$AUTH_CODE&state=par-state") + + Assert.assertTrue(resume(intent)) + + verify(callback).onSuccess(authCodeCaptor.capture()) + val authCode = authCodeCaptor.firstValue + assertThat(authCode.code, `is`(AUTH_CODE)) + assertThat(authCode.state, `is`("par-state")) + } + + @Test + public fun shouldFailWithMissingCode() { + authorizeWithRequestUri(account) + .start(activity, REQUEST_URI, callback) + + verify(activity).startActivity(intentCaptor.capture()) + + val intent = createAuthIntent("foo=bar") + + Assert.assertTrue(resume(intent)) + + verify(callback).onFailure(authExceptionCaptor.capture()) + val exception = authExceptionCaptor.firstValue + assertThat(exception, `is`(notNullValue())) + assertThat(exception.isAccessDenied, `is`(true)) + } + + @Test + public fun shouldFailWithErrorResponse() { + authorizeWithRequestUri(account) + .start(activity, REQUEST_URI, callback) + + verify(activity).startActivity(intentCaptor.capture()) + + val intent = createAuthIntent("error=access_denied&error_description=User%20denied%20access") + + Assert.assertTrue(resume(intent)) + + verify(callback).onFailure(authExceptionCaptor.capture()) + val exception = authExceptionCaptor.firstValue + assertThat(exception, `is`(notNullValue())) + assertThat(exception.getCode(), `is`("access_denied")) + } + + @Test + public fun shouldHandleCanceledAuthentication() { + authorizeWithRequestUri(account) + .start(activity, REQUEST_URI, callback) + + verify(activity).startActivity(intentCaptor.capture()) + + val intent = Intent() + + Assert.assertTrue(resume(intent)) + + verify(callback).onFailure(authExceptionCaptor.capture()) + val exception = authExceptionCaptor.firstValue + assertThat(exception, `is`(notNullValue())) + assertThat(exception.isCanceled, `is`(true)) + } + + @Test + public fun shouldFailWhenNoBrowserAvailable() { + BrowserPickerTest.setupBrowserContext( + activity, + emptyList(), + null, + null + ) + + authorizeWithRequestUri(account) + .start(activity, REQUEST_URI, callback) + + verify(callback).onFailure(authExceptionCaptor.capture()) + val exception = authExceptionCaptor.firstValue + assertThat(exception, `is`(notNullValue())) + assertThat(exception.isBrowserAppNotAvailable, `is`(true)) + } + + @Test + public fun shouldIncludeSessionTransferTokenInAuthorizeUri() { + authorizeWithRequestUri(account) + .withSessionTransferToken(SESSION_TRANSFER_TOKEN) + .start(activity, REQUEST_URI, callback) + + verify(activity).startActivity(intentCaptor.capture()) + val uri = intentCaptor.firstValue.getParcelableExtra(AuthenticationActivity.EXTRA_AUTHORIZE_URI) + + assertThat(uri, `is`(notNullValue())) + assertThat(uri?.getQueryParameter("client_id"), `is`(CLIENT_ID)) + assertThat(uri?.getQueryParameter("request_uri"), `is`(REQUEST_URI)) + assertThat(uri?.getQueryParameter("session_transfer_token"), `is`(SESSION_TRANSFER_TOKEN)) + } + + @Test + public fun shouldNotIncludeSessionTransferTokenWhenNotProvided() { + authorizeWithRequestUri(account) + .start(activity, REQUEST_URI, callback) + + verify(activity).startActivity(intentCaptor.capture()) + val uri = intentCaptor.firstValue.getParcelableExtra(AuthenticationActivity.EXTRA_AUTHORIZE_URI) + + assertThat(uri, `is`(notNullValue())) + assertThat(uri?.getQueryParameter("session_transfer_token"), `is`(org.hamcrest.CoreMatchers.nullValue())) + } + + @Test + public fun shouldFailWithInvalidRequestUri() { + authorizeWithRequestUri(account) + .start(activity, "invalid-request-uri", callback) + + verify(callback).onFailure(authExceptionCaptor.capture()) + val exception = authExceptionCaptor.firstValue + assertThat(exception, `is`(notNullValue())) + assertThat(exception.getCode(), `is`("a0.invalid_request_uri")) + } + + private fun createAuthIntent(queryString: String): Intent { + val uri = Uri.parse("https://$DOMAIN/android/com.auth0.test/callback?$queryString") + return Intent().apply { + data = uri + } + } +} diff --git a/auth0/src/test/java/com/auth0/android/provider/WebAuthProviderTest.kt b/auth0/src/test/java/com/auth0/android/provider/WebAuthProviderTest.kt index d603d3456..e033aa8df 100644 --- a/auth0/src/test/java/com/auth0/android/provider/WebAuthProviderTest.kt +++ b/auth0/src/test/java/com/auth0/android/provider/WebAuthProviderTest.kt @@ -3022,4 +3022,88 @@ public class WebAuthProviderTest { private const val KEY_STATE = "state" private const val KEY_NONCE = "nonce" } + + + @Test + public fun shouldAuthorizeWithRequestUri() { + val requestUri = "urn:ietf:params:oauth:request_uri:6esc_11ACC5bwc014ltc14eY22c" + val parCallback: Callback = mock() + + WebAuthProvider.authorizeWithRequestUri(account) + .start(activity, requestUri, parCallback) + + Assert.assertNotNull(WebAuthProvider.managerInstance) + verify(activity).startActivity(intentCaptor.capture()) + val uri = intentCaptor.firstValue.getParcelableExtra(AuthenticationActivity.EXTRA_AUTHORIZE_URI) + + assertThat(uri, `is`(notNullValue())) + assertThat(uri?.getQueryParameter("client_id"), `is`(JwtTestUtils.EXPECTED_AUDIENCE)) + assertThat(uri?.getQueryParameter("request_uri"), `is`(requestUri)) + } + + @Test + public fun shouldAuthorizeWithRequestUriAndSessionTransferToken() { + val requestUri = "urn:ietf:params:oauth:request_uri:6esc_11ACC5bwc014ltc14eY22c" + val sessionTransferToken = "stt_test_token_value" + val parCallback: Callback = mock() + + WebAuthProvider.authorizeWithRequestUri(account) + .withSessionTransferToken(sessionTransferToken) + .start(activity, requestUri, parCallback) + + verify(activity).startActivity(intentCaptor.capture()) + val uri = intentCaptor.firstValue.getParcelableExtra(AuthenticationActivity.EXTRA_AUTHORIZE_URI) + + assertThat(uri, `is`(notNullValue())) + assertThat(uri?.getQueryParameter("client_id"), `is`(JwtTestUtils.EXPECTED_AUDIENCE)) + assertThat(uri?.getQueryParameter("request_uri"), `is`(requestUri)) + assertThat(uri?.getQueryParameter("session_transfer_token"), `is`(sessionTransferToken)) + } + + @Test + public fun shouldFailAuthorizeWithRequestUriWhenInvalidRequestUri() { + val parCallback: Callback = mock() + val exceptionCaptor: KArgumentCaptor = argumentCaptor() + + WebAuthProvider.authorizeWithRequestUri(account) + .start(activity, "invalid-uri", parCallback) + + verify(parCallback).onFailure(exceptionCaptor.capture()) + assertThat(exceptionCaptor.firstValue.getCode(), `is`("a0.invalid_request_uri")) + } + + @Test + public fun shouldFailAuthorizeWithRequestUriWhenNoBrowserAvailable() { + BrowserPickerTest.setupBrowserContext(activity, emptyList(), null, null) + val requestUri = "urn:ietf:params:oauth:request_uri:6esc_11ACC5bwc014ltc14eY22c" + val parCallback: Callback = mock() + val exceptionCaptor: KArgumentCaptor = argumentCaptor() + + WebAuthProvider.authorizeWithRequestUri(account) + .start(activity, requestUri, parCallback) + + verify(parCallback).onFailure(exceptionCaptor.capture()) + assertThat(exceptionCaptor.firstValue.isBrowserAppNotAvailable, `is`(true)) + } + + @Test + public fun shouldResumeAuthorizeWithRequestUriWithCode() { + val requestUri = "urn:ietf:params:oauth:request_uri:6esc_11ACC5bwc014ltc14eY22c" + val parCallback: Callback = mock() + val codeCaptor: KArgumentCaptor = argumentCaptor() + + WebAuthProvider.authorizeWithRequestUri(account) + .start(activity, requestUri, parCallback) + + verify(activity).startActivity(intentCaptor.capture()) + + val intent = Intent().apply { + data = Uri.parse("https://${JwtTestUtils.EXPECTED_BASE_DOMAIN}/android/com.auth0.test/callback?code=test-code&state=test-state") + } + Assert.assertTrue(resume(intent)) + + verify(parCallback).onSuccess(codeCaptor.capture()) + assertThat(codeCaptor.firstValue.code, `is`("test-code")) + assertThat(codeCaptor.firstValue.state, `is`("test-state")) + } } \ No newline at end of file From 346c9d862515736e2978764f7bda56019155a50d Mon Sep 17 00:00:00 2001 From: Prince Mathew <17837162+pmathew92@users.noreply.github.com> Date: Tue, 19 May 2026 23:39:26 +0530 Subject: [PATCH 29/60] feat : Added additional properties support to passkeys signup API (#969) --- EXAMPLES.md | 27 ++++++++++- .../authentication/AuthenticationAPIClient.kt | 23 ++++++---- .../com/auth0/android/request/UserData.kt | 14 +++++- .../AuthenticationAPIClientTest.kt | 46 ++++++++++++++++++- 4 files changed, 98 insertions(+), 12 deletions(-) diff --git a/EXAMPLES.md b/EXAMPLES.md index 1fc8ca1ee..52ea9eae9 100644 --- a/EXAMPLES.md +++ b/EXAMPLES.md @@ -2800,8 +2800,19 @@ To sign up a user with passkey ```kotlin // Using Coroutines try { + val userData = UserData( + email = "user@example.com", + phoneNumber = "+11234567890", + name = "John Doe", + givenName = "John", + familyName = "Doe", + nickName = "johnny", + picture = "https://example.com/photo.png", + userMetadata = mapOf("signup_source" to "android_app") + ) + val challenge = authenticationApiClient.signupWithPasskey( - "{user-data}", + userData, "{realm}", "{organization-id}" ).await() @@ -2831,7 +2842,19 @@ try { Using Java ```java - authenticationAPIClient.signupWithPasskey("{user-data}", "{realm}","{organization-id}") + UserData userData = new UserData( + "user@example.com", // email + "+11234567890", // phoneNumber + null, // userName + "John Doe", // name + "John", // givenName + "Doe", // familyName + "johnny", // nickName + "https://example.com/photo.png", // picture + Map.of("signup_source", "android_app") // userMetadata + ); + + authenticationAPIClient.signupWithPasskey(userData, "{realm}","{organization-id}") .start(new Callback() { @Override public void onSuccess(PasskeyRegistrationChallenge result) { 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 f33aee9ca..1f986e917 100755 --- a/auth0/src/main/java/com/auth0/android/authentication/AuthenticationAPIClient.kt +++ b/auth0/src/main/java/com/auth0/android/authentication/AuthenticationAPIClient.kt @@ -313,17 +313,24 @@ public class AuthenticationAPIClient @VisibleForTesting(otherwise = VisibleForTe * * Example usage: * - * * ``` - * client.signupWithPasskey("{userData}","{realm}","{organization}") - * .addParameter("scope","scope") - * .start(object: Callback { - * override fun onSuccess(result: PasskeyRegistration) { } + * val userData = UserData( + * email = "user@example.com", + * name = "John Doe", + * givenName = "John", + * familyName = "Doe", + * nickName = "johnny", + * picture = "https://example.com/photo.png", + * userMetadata = mapOf("signup_source" to "android_app") + * ) + * client.signupWithPasskey(userData, "{realm}", "{organization}") + * .start(object: Callback { + * override fun onSuccess(result: PasskeyRegistrationChallenge) { } * override fun onFailure(error: AuthenticationException) { } * }) * ``` * - * @param userData user information of the client + * @param userData user information for registration. * @param realm the connection to use. If excluded, the application will use the default connection configured in the tenant * @param organization id of the organization to be associated with the user while signing up * @return a request to configure and start that will yield [PasskeyRegistrationChallenge] @@ -333,7 +340,6 @@ public class AuthenticationAPIClient @VisibleForTesting(otherwise = VisibleForTe realm: String? = null, organization: String? = null ): Request { - val user = gson.toJsonTree(userData) val url = auth0.getDomainUrl().toHttpUrl().newBuilder() .addPathSegment(PASSKEY_PATH) .addPathSegment(REGISTER_PATH) @@ -351,7 +357,8 @@ public class AuthenticationAPIClient @VisibleForTesting(otherwise = VisibleForTe ) val post = factory.post(url.toString(), passkeyRegistrationChallengeAdapter) .addParameters(params) as BaseRequest - post.addParameter(USER_PROFILE_KEY, user) + post.addParameter(USER_PROFILE_KEY, gson.toJsonTree(userData.toUserProfile())) + userData.userMetadata?.let { post.addParameter(USER_METADATA_KEY, it) } return post } diff --git a/auth0/src/main/java/com/auth0/android/request/UserData.kt b/auth0/src/main/java/com/auth0/android/request/UserData.kt index 693b64368..f1bc73f6d 100644 --- a/auth0/src/main/java/com/auth0/android/request/UserData.kt +++ b/auth0/src/main/java/com/auth0/android/request/UserData.kt @@ -8,10 +8,22 @@ import com.google.gson.annotations.SerializedName * @param phoneNumber the phone number of the user. phone number can be optional, required, or forbidden depending on the attribute configuration for the database * @param userName the username of the user. username can be optional, required, or forbidden depending on the attribute configuration for the database * @param name optional display name + * @param givenName the first name of the user + * @param familyName the last name of the user + * @param nickName the preferred nickname of the user + * @param picture URL pointing to the user's profile picture + * @param userMetadata additional user metadata as key-value pairs */ public data class UserData( @field:SerializedName("email") val email: String? = null, @field:SerializedName("phone_number") val phoneNumber: String? = null, @field:SerializedName("username") val userName: String? = null, @field:SerializedName("name") val name: String? = null, -) \ No newline at end of file + @field:SerializedName("given_name") val givenName: String? = null, + @field:SerializedName("family_name") val familyName: String? = null, + @field:SerializedName("nickname") val nickName: String? = null, + @field:SerializedName("picture") val picture: String? = null, + @field:SerializedName("user_metadata") val userMetadata: Map? = null, +) { + internal fun toUserProfile(): UserData = copy(userMetadata = null) +} \ No newline at end of file diff --git a/auth0/src/test/java/com/auth0/android/authentication/AuthenticationAPIClientTest.kt b/auth0/src/test/java/com/auth0/android/authentication/AuthenticationAPIClientTest.kt index 1eb962e33..acc23a85a 100755 --- a/auth0/src/test/java/com/auth0/android/authentication/AuthenticationAPIClientTest.kt +++ b/auth0/src/test/java/com/auth0/android/authentication/AuthenticationAPIClientTest.kt @@ -13,6 +13,7 @@ import com.auth0.android.provider.JwtTestUtils import com.auth0.android.request.HttpMethod import com.auth0.android.request.NetworkingClient import com.auth0.android.request.PublicKeyCredentials +import com.auth0.android.request.UserData import com.auth0.android.request.RequestOptions import com.auth0.android.request.ServerResponse import com.auth0.android.request.internal.RequestFactory @@ -243,7 +244,7 @@ public class AuthenticationAPIClientTest { val auth0 = auth0 val client = AuthenticationAPIClient(auth0) val registrationResponse = client.signupWithPasskey( - mock(), + UserData(email = "test@example.com"), MY_CONNECTION, "testOrganization" ) @@ -264,6 +265,49 @@ public class AuthenticationAPIClientTest { assertThat(registrationResponse.authSession, Matchers.comparesEqualTo(SESSION_ID)) } + @Test + public fun shouldSignupWithPasskeyWithAllUserDataFields() { + mockAPI.willReturnSuccessfulPasskeyRegistration() + val auth0 = auth0 + val client = AuthenticationAPIClient(auth0) + val userData = UserData( + email = "test@example.com", + phoneNumber = "+1234567890", + userName = "testuser", + name = "Test User", + givenName = "Test", + familyName = "User", + nickName = "testy", + picture = "https://example.com/photo.png", + userMetadata = mapOf("key1" to "value1") + ) + val registrationResponse = client.signupWithPasskey( + userData, + MY_CONNECTION, + "testOrganization" + ).execute() + val request = mockAPI.takeRequest() + val body = bodyFromRequest(request) + assertThat(request.path, Matchers.equalTo("/passkey/register")) + assertThat(body, Matchers.hasKey("user_profile")) + @Suppress("UNCHECKED_CAST") + val userProfile = body["user_profile"] as Map + assertThat(userProfile, Matchers.hasEntry("email", "test@example.com")) + assertThat(userProfile, Matchers.hasEntry("phone_number", "+1234567890")) + assertThat(userProfile, Matchers.hasEntry("username", "testuser")) + assertThat(userProfile, Matchers.hasEntry("name", "Test User")) + assertThat(userProfile, Matchers.hasEntry("given_name", "Test")) + assertThat(userProfile, Matchers.hasEntry("family_name", "User")) + assertThat(userProfile, Matchers.hasEntry("nickname", "testy")) + assertThat(userProfile, Matchers.hasEntry("picture", "https://example.com/photo.png")) + assertThat(userProfile, Matchers.not(Matchers.hasKey("user_metadata"))) + assertThat(body, Matchers.hasKey("user_metadata")) + @Suppress("UNCHECKED_CAST") + val metadata = body["user_metadata"] as Map + assertThat(metadata, Matchers.hasEntry("key1", "value1")) + assertThat(registrationResponse, Matchers.`is`(Matchers.notNullValue())) + } + @Test public fun shouldGetPasskeyChallenge() { mockAPI.willReturnSuccessfulPasskeyChallenge() From 6dcccc1064884a8fc22507eb8bebc5d11d327e1e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 20 May 2026 10:40:42 +0530 Subject: [PATCH 30/60] chore(deps): bump codecov/codecov-action from 6.0.0 to 6.0.1 (#971) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index d13ae647d..cf0560d61 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -29,4 +29,4 @@ jobs: - run: ./gradlew clean test jacocoTestReport lint --continue --console=plain --max-workers=1 --no-daemon - - uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # pin@6.0.0 + - uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # pin@6.0.1 From 459b834e1c55e130603831908fe3d7926f373e85 Mon Sep 17 00:00:00 2001 From: Prince Mathew <17837162+pmathew92@users.noreply.github.com> Date: Thu, 21 May 2026 08:50:27 +0530 Subject: [PATCH 31/60] Release 3.17.0 (#973) --- .version | 2 +- CHANGELOG.md | 7 +++++++ README.md | 2 +- 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/.version b/.version index 7ccd8d446..f85bf6e32 100644 --- a/.version +++ b/.version @@ -1 +1 @@ -3.16.0 \ No newline at end of file +3.17.0 \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index da08e3dc2..fa574f9f0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Change Log +## [3.17.0](https://github.com/auth0/Auth0.Android/tree/3.17.0) (2026-05-21) +[Full Changelog](https://github.com/auth0/Auth0.Android/compare/3.16.0...3.17.0) + +**Added** +- feat : Added additional properties support to passkeys signup API [\#969](https://github.com/auth0/Auth0.Android/pull/969) ([pmathew92](https://github.com/pmathew92)) +- feat :Add partial support for PAR auth flow [\#967](https://github.com/auth0/Auth0.Android/pull/967) ([pmathew92](https://github.com/pmathew92)) + ## [3.16.0](https://github.com/auth0/Auth0.Android/tree/3.16.0) (2026-04-27) [Full Changelog](https://github.com/auth0/Auth0.Android/compare/3.15.0...3.16.0) diff --git a/README.md b/README.md index c068786bf..fb107093b 100644 --- a/README.md +++ b/README.md @@ -60,7 +60,7 @@ To install Auth0.Android with [Gradle](https://gradle.org/), simply add the foll ```gradle dependencies { - implementation 'com.auth0.android:auth0:3.16.0' + implementation 'com.auth0.android:auth0:3.17.0' } ``` From e891247597a8f17fb56361fcc9cee7ede1e806bc Mon Sep 17 00:00:00 2001 From: Prince Mathew <17837162+pmathew92@users.noreply.github.com> Date: Mon, 25 May 2026 11:40:29 +0530 Subject: [PATCH 32/60] fix : Guard against all uncaught exception in the serial executor block (#970) --- .../storage/BaseCredentialsManager.kt | 13 + .../storage/CredentialsManager.kt | 420 ++++++------- .../storage/SecureCredentialsManager.kt | 569 +++++++++--------- .../storage/CredentialsManagerTest.kt | 78 +++ .../storage/SecureCredentialsManagerTest.kt | 69 +++ 5 files changed, 637 insertions(+), 512 deletions(-) diff --git a/auth0/src/main/java/com/auth0/android/authentication/storage/BaseCredentialsManager.kt b/auth0/src/main/java/com/auth0/android/authentication/storage/BaseCredentialsManager.kt index be2aa1738..c3ce2133a 100644 --- a/auth0/src/main/java/com/auth0/android/authentication/storage/BaseCredentialsManager.kt +++ b/auth0/src/main/java/com/auth0/android/authentication/storage/BaseCredentialsManager.kt @@ -313,6 +313,19 @@ public abstract class BaseCredentialsManager internal constructor( if (scope == null) return audience val sortedScope = scope.split(" ").sorted().joinToString("::") return "$audience::${sortedScope}" + } + internal inline fun runCatchingOnExecutor( + callback: Callback, + block: () -> Unit + ) { + try { + block() + } catch (t: Throwable) { + Log.e("BaseCredentialsManager", "Unexpected error in executor block", t) + callback.onFailure( + CredentialsManagerException(CredentialsManagerException.Code.UNKNOWN_ERROR, t) + ) + } } } 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 f9a56919b..31c865f02 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 @@ -128,48 +128,39 @@ public class CredentialsManager @VisibleForTesting(otherwise = VisibleForTesting callback: Callback ) { serialExecutor.execute { - val refreshToken = storage.retrieveString(KEY_REFRESH_TOKEN) - if (refreshToken.isNullOrEmpty()) { - callback.onFailure(CredentialsManagerException.NO_REFRESH_TOKEN) - return@execute - } - - val tokenType = storage.retrieveString(KEY_TOKEN_TYPE) - validateDPoPState(tokenType)?.let { dpopError -> - callback.onFailure(dpopError) - return@execute - } - - val request = authenticationClient.ssoExchange(refreshToken) - try { - if (parameters.isNotEmpty()) { - request.addParameters(parameters) + runCatchingOnExecutor(callback) { + val refreshToken = storage.retrieveString(KEY_REFRESH_TOKEN) + if (refreshToken.isNullOrEmpty()) { + callback.onFailure(CredentialsManagerException.NO_REFRESH_TOKEN) + return@execute } - val sessionTransferCredentials = request.execute() - saveSsoCredentials(sessionTransferCredentials) - callback.onSuccess(sessionTransferCredentials) - } catch (error: AuthenticationException) { - val exception = when { - error.isNetworkError -> CredentialsManagerException.Code.NO_NETWORK - else -> CredentialsManagerException.Code.SSO_EXCHANGE_FAILED + + val tokenType = storage.retrieveString(KEY_TOKEN_TYPE) + validateDPoPState(tokenType)?.let { dpopError -> + callback.onFailure(dpopError) + return@execute } - callback.onFailure( - CredentialsManagerException( - exception, - error - ) - ) - } catch (exception: RuntimeException) { - Log.e( - TAG, - "Caught unexpected exceptions while fetching sso token ${exception.stackTraceToString()}" - ) - callback.onFailure( - CredentialsManagerException( - CredentialsManagerException.Code.UNKNOWN_ERROR, - exception + + val request = authenticationClient.ssoExchange(refreshToken) + try { + if (parameters.isNotEmpty()) { + request.addParameters(parameters) + } + val sessionTransferCredentials = request.execute() + saveSsoCredentials(sessionTransferCredentials) + callback.onSuccess(sessionTransferCredentials) + } catch (error: AuthenticationException) { + val exception = when { + error.isNetworkError -> CredentialsManagerException.Code.NO_NETWORK + else -> CredentialsManagerException.Code.SSO_EXCHANGE_FAILED + } + callback.onFailure( + CredentialsManagerException( + exception, + error + ) ) - ) + } } } } @@ -458,119 +449,108 @@ public class CredentialsManager @VisibleForTesting(otherwise = VisibleForTesting callback: Callback ) { serialExecutor.execute { - val accessToken = storage.retrieveString(KEY_ACCESS_TOKEN) - val refreshToken = storage.retrieveString(KEY_REFRESH_TOKEN) - val idToken = storage.retrieveString(KEY_ID_TOKEN) - val tokenType = storage.retrieveString(KEY_TOKEN_TYPE) - val expiresAt = storage.retrieveLong(KEY_EXPIRES_AT) - val storedScope = storage.retrieveString(KEY_SCOPE) - val hasEmptyCredentials = - TextUtils.isEmpty(accessToken) && TextUtils.isEmpty(idToken) || expiresAt == null - if (hasEmptyCredentials) { - callback.onFailure(CredentialsManagerException.NO_CREDENTIALS) - return@execute - } - val willAccessTokenExpire = willExpire(expiresAt!!, minTtl.toLong()) - val scopeChanged = hasScopeChanged(storedScope, scope) - if (!forceRefresh && !willAccessTokenExpire && !scopeChanged) { - callback.onSuccess( - recreateCredentials( - idToken.orEmpty(), - accessToken.orEmpty(), - tokenType.orEmpty(), - refreshToken, - Date(expiresAt), - storedScope + runCatchingOnExecutor(callback) { + val accessToken = storage.retrieveString(KEY_ACCESS_TOKEN) + val refreshToken = storage.retrieveString(KEY_REFRESH_TOKEN) + val idToken = storage.retrieveString(KEY_ID_TOKEN) + val tokenType = storage.retrieveString(KEY_TOKEN_TYPE) + val expiresAt = storage.retrieveLong(KEY_EXPIRES_AT) + val storedScope = storage.retrieveString(KEY_SCOPE) + val hasEmptyCredentials = + TextUtils.isEmpty(accessToken) && TextUtils.isEmpty(idToken) || expiresAt == null + if (hasEmptyCredentials) { + callback.onFailure(CredentialsManagerException.NO_CREDENTIALS) + return@execute + } + val willAccessTokenExpire = willExpire(expiresAt!!, minTtl.toLong()) + val scopeChanged = hasScopeChanged(storedScope, scope) + if (!forceRefresh && !willAccessTokenExpire && !scopeChanged) { + callback.onSuccess( + recreateCredentials( + idToken.orEmpty(), + accessToken.orEmpty(), + tokenType.orEmpty(), + refreshToken, + Date(expiresAt), + storedScope + ) ) - ) - return@execute - } - if (refreshToken == null) { - callback.onFailure(CredentialsManagerException.NO_REFRESH_TOKEN) - return@execute - } - validateDPoPState(tokenType)?.let { dpopError -> - callback.onFailure(dpopError) - return@execute - } - val request = authenticationClient.renewAuth(refreshToken) - request.addParameters(parameters) - if (scope != null) { - request.addParameter("scope", scope) - } + return@execute + } + if (refreshToken == null) { + callback.onFailure(CredentialsManagerException.NO_REFRESH_TOKEN) + return@execute + } + validateDPoPState(tokenType)?.let { dpopError -> + callback.onFailure(dpopError) + return@execute + } + val request = authenticationClient.renewAuth(refreshToken) + request.addParameters(parameters) + if (scope != null) { + request.addParameter("scope", scope) + } - for (header in headers) { - request.addHeader(header.key, header.value) - } + for (header in headers) { + request.addHeader(header.key, header.value) + } - try { - val fresh = request.execute() - val expiresAt = fresh.expiresAt.time - val willAccessTokenExpire = willExpire(expiresAt, minTtl.toLong()) - if (willAccessTokenExpire) { - val tokenLifetime = (expiresAt - currentTimeInMillis - minTtl * 1000) / -1000 - val wrongTtlException = CredentialsManagerException( - CredentialsManagerException.Code.LARGE_MIN_TTL, String.format( - Locale.getDefault(), - "The lifetime of the renewed Access Token (%d) is less than the minTTL requested (%d). Increase the 'Token Expiration' setting of your Auth0 API in the dashboard, or request a lower minTTL.", - tokenLifetime, - minTtl + try { + val fresh = request.execute() + val expiresAt = fresh.expiresAt.time + val willAccessTokenExpire = willExpire(expiresAt, minTtl.toLong()) + if (willAccessTokenExpire) { + val tokenLifetime = + (expiresAt - currentTimeInMillis - minTtl * 1000) / -1000 + val wrongTtlException = CredentialsManagerException( + CredentialsManagerException.Code.LARGE_MIN_TTL, String.format( + Locale.getDefault(), + "The lifetime of the renewed Access Token (%d) is less than the minTTL requested (%d). Increase the 'Token Expiration' setting of your Auth0 API in the dashboard, or request a lower minTTL.", + tokenLifetime, + minTtl + ) ) + callback.onFailure(wrongTtlException) + return@execute + } + + val updatedRefreshToken = + if (TextUtils.isEmpty(fresh.refreshToken)) refreshToken else fresh.refreshToken + val credentials = Credentials( + fresh.idToken, + fresh.accessToken, + fresh.type, + updatedRefreshToken, + fresh.expiresAt, + fresh.scope ) - callback.onFailure(wrongTtlException) - return@execute - } + saveCredentials(credentials) + callback.onSuccess(credentials) + } catch (error: AuthenticationException) { + if (error.isMultifactorRequired) { + callback.onFailure( + CredentialsManagerException( + CredentialsManagerException.Code.MFA_REQUIRED, + error.message + ?: "Multi-factor authentication is required to complete the credential renewal.", + error, + error.mfaRequiredErrorPayload + ) + ) + return@execute + } + val exception = when { + error.isRefreshTokenDeleted || error.isInvalidRefreshToken -> CredentialsManagerException.Code.RENEW_FAILED - // non-empty refresh token for refresh token rotation scenarios - val updatedRefreshToken = - if (TextUtils.isEmpty(fresh.refreshToken)) refreshToken else fresh.refreshToken - val credentials = Credentials( - fresh.idToken, - fresh.accessToken, - fresh.type, - updatedRefreshToken, - fresh.expiresAt, - fresh.scope - ) - saveCredentials(credentials) - callback.onSuccess(credentials) - } catch (error: AuthenticationException) { - if (error.isMultifactorRequired) { + error.isNetworkError -> CredentialsManagerException.Code.NO_NETWORK + else -> CredentialsManagerException.Code.API_ERROR + } callback.onFailure( CredentialsManagerException( - CredentialsManagerException.Code.MFA_REQUIRED, - error.message ?: "Multi-factor authentication is required to complete the credential renewal.", - error, - error.mfaRequiredErrorPayload + exception, error ) ) - return@execute - } - val exception = when { - error.isRefreshTokenDeleted || error.isInvalidRefreshToken -> CredentialsManagerException.Code.RENEW_FAILED - - error.isNetworkError -> CredentialsManagerException.Code.NO_NETWORK - else -> CredentialsManagerException.Code.API_ERROR } - callback.onFailure( - CredentialsManagerException( - exception, error - ) - ) - } catch (exception: RuntimeException) { - /** - * Catching any unexpected runtime errors in the token renewal flow - */ - Log.e( - TAG, - "Caught unexpected exceptions for token renewal ${exception.stackTraceToString()}" - ) - callback.onFailure( - CredentialsManagerException( - CredentialsManagerException.Code.UNKNOWN_ERROR, - exception - ) - ) } } } @@ -598,101 +578,101 @@ public class CredentialsManager @VisibleForTesting(otherwise = VisibleForTesting headers: Map, callback: Callback ) { - serialExecutor.execute { - //Check if existing api credentials are present and valid - val key = getAPICredentialsKey(audience, scope) - val apiCredentialsJson = storage.retrieveString(key) - var apiCredentialType: String? = null - apiCredentialsJson?.let { - val apiCredentials = gson.fromJson(it, APICredentials::class.java) - apiCredentialType = apiCredentials.type - val willTokenExpire = willExpire(apiCredentials.expiresAt.time, minTtl.toLong()) - - val scopeChanged = hasScopeChanged( - apiCredentials.scope, - scope, - ignoreOpenid = scope?.contains("openid") == false - ) - - val hasExpired = hasExpired(apiCredentials.expiresAt.time) - - if (!hasExpired && !willTokenExpire && !scopeChanged) { - callback.onSuccess(apiCredentials) + runCatchingOnExecutor(callback) { + val key = getAPICredentialsKey(audience, scope) + val apiCredentialsJson = storage.retrieveString(key) + var apiCredentialType: String? = null + apiCredentialsJson?.let { + val apiCredentials = gson.fromJson(it, APICredentials::class.java) + apiCredentialType = apiCredentials.type + val willTokenExpire = + willExpire(apiCredentials.expiresAt.time, minTtl.toLong()) + + val scopeChanged = hasScopeChanged( + apiCredentials.scope, + scope, + ignoreOpenid = scope?.contains("openid") == false + ) + + val hasExpired = hasExpired(apiCredentials.expiresAt.time) + + if (!hasExpired && !willTokenExpire && !scopeChanged) { + callback.onSuccess(apiCredentials) + return@execute + } + } + val refreshToken = storage.retrieveString(KEY_REFRESH_TOKEN) + if (refreshToken == null) { + callback.onFailure(CredentialsManagerException.NO_REFRESH_TOKEN) return@execute } - } - //Check if refresh token exists or not - val refreshToken = storage.retrieveString(KEY_REFRESH_TOKEN) - if (refreshToken == null) { - callback.onFailure(CredentialsManagerException.NO_REFRESH_TOKEN) - return@execute - } - val tokenType = apiCredentialType ?: storage.retrieveString(KEY_TOKEN_TYPE) - validateDPoPState(tokenType)?.let { dpopError -> - callback.onFailure(dpopError) - return@execute - } + val tokenType = apiCredentialType ?: storage.retrieveString(KEY_TOKEN_TYPE) + validateDPoPState(tokenType)?.let { dpopError -> + callback.onFailure(dpopError) + return@execute + } - val request = authenticationClient.renewAuth(refreshToken, audience, scope) - request.addParameters(parameters) + val request = authenticationClient.renewAuth(refreshToken, audience, scope) + request.addParameters(parameters) - for (header in headers) { - request.addHeader(header.key, header.value) - } + for (header in headers) { + request.addHeader(header.key, header.value) + } - try { - val newCredentials = request.execute() - val expiresAt = newCredentials.expiresAt.time - val willAccessTokenExpire = willExpire(expiresAt, minTtl.toLong()) - if (willAccessTokenExpire) { - val tokenLifetime = (expiresAt - currentTimeInMillis - minTtl * 1000) / -1000 - val wrongTtlException = CredentialsManagerException( - CredentialsManagerException.Code.LARGE_MIN_TTL, String.format( - Locale.getDefault(), - "The lifetime of the renewed Access Token (%d) is less than the minTTL requested (%d). Increase the 'Token Expiration' setting of your Auth0 API in the dashboard, or request a lower minTTL.", - tokenLifetime, - minTtl + try { + val newCredentials = request.execute() + val expiresAt = newCredentials.expiresAt.time + val willAccessTokenExpire = willExpire(expiresAt, minTtl.toLong()) + if (willAccessTokenExpire) { + val tokenLifetime = + (expiresAt - currentTimeInMillis - minTtl * 1000) / -1000 + val wrongTtlException = CredentialsManagerException( + CredentialsManagerException.Code.LARGE_MIN_TTL, String.format( + Locale.getDefault(), + "The lifetime of the renewed Access Token (%d) is less than the minTTL requested (%d). Increase the 'Token Expiration' setting of your Auth0 API in the dashboard, or request a lower minTTL.", + tokenLifetime, + minTtl + ) ) - ) - callback.onFailure(wrongTtlException) - return@execute - } + callback.onFailure(wrongTtlException) + return@execute + } - // non-empty refresh token for refresh token rotation scenarios - val updatedRefreshToken = - if (TextUtils.isEmpty(newCredentials.refreshToken)) refreshToken else newCredentials.refreshToken - val newApiCredentials = newCredentials.toAPICredentials() - storage.store(KEY_REFRESH_TOKEN, updatedRefreshToken) - storage.store(KEY_ID_TOKEN, newCredentials.idToken) - saveApiCredentials(newApiCredentials, audience, scope) - callback.onSuccess(newApiCredentials) - } catch (error: AuthenticationException) { - if (error.isMultifactorRequired) { + val updatedRefreshToken = + if (TextUtils.isEmpty(newCredentials.refreshToken)) refreshToken else newCredentials.refreshToken + val newApiCredentials = newCredentials.toAPICredentials() + storage.store(KEY_REFRESH_TOKEN, updatedRefreshToken) + storage.store(KEY_ID_TOKEN, newCredentials.idToken) + saveApiCredentials(newApiCredentials, audience, scope) + callback.onSuccess(newApiCredentials) + } catch (error: AuthenticationException) { + if (error.isMultifactorRequired) { + callback.onFailure( + CredentialsManagerException( + CredentialsManagerException.Code.MFA_REQUIRED, + error.message + ?: "Multi-factor authentication is required to complete the credential renewal.", + error, + error.mfaRequiredErrorPayload + ) + ) + return@execute + } + val exception = when { + error.isRefreshTokenDeleted || error.isInvalidRefreshToken -> CredentialsManagerException.Code.RENEW_FAILED + error.isNetworkError -> CredentialsManagerException.Code.NO_NETWORK + else -> CredentialsManagerException.Code.API_ERROR + } callback.onFailure( CredentialsManagerException( - CredentialsManagerException.Code.MFA_REQUIRED, - error.message ?: "Multi-factor authentication is required to complete the credential renewal.", - error, - error.mfaRequiredErrorPayload + exception, error ) ) - return@execute - } - val exception = when { - error.isRefreshTokenDeleted || error.isInvalidRefreshToken -> CredentialsManagerException.Code.RENEW_FAILED - error.isNetworkError -> CredentialsManagerException.Code.NO_NETWORK - else -> CredentialsManagerException.Code.API_ERROR } - callback.onFailure( - CredentialsManagerException( - exception, error - ) - ) } } - } /** 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 ef51b4d7a..a982e2634 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 @@ -270,59 +270,50 @@ public class SecureCredentialsManager @VisibleForTesting(otherwise = VisibleForT callback: Callback ) { serialExecutor.execute { - val existingCredentials: Credentials = try { - getExistingCredentials() - } catch (exception: CredentialsManagerException) { - Log.e(TAG, "Error while fetching existing credentials", exception) - callback.onFailure(exception) - return@execute - } - if (existingCredentials.refreshToken.isNullOrEmpty()) { - callback.onFailure(CredentialsManagerException.NO_REFRESH_TOKEN) - return@execute - } - - val tokenType = storage.retrieveString(KEY_TOKEN_TYPE) ?: existingCredentials.type - validateDPoPState(tokenType)?.let { dpopError -> - callback.onFailure(dpopError) - return@execute - } - - val request = - authenticationClient.ssoExchange(existingCredentials.refreshToken) - try { - if (parameters.isNotEmpty()) { - request.addParameters(parameters) + runCatchingOnExecutor(callback) { + val existingCredentials: Credentials = try { + getExistingCredentials() + } catch (exception: CredentialsManagerException) { + Log.e(TAG, "Error while fetching existing credentials", exception) + callback.onFailure(exception) + return@execute + } + if (existingCredentials.refreshToken.isNullOrEmpty()) { + callback.onFailure(CredentialsManagerException.NO_REFRESH_TOKEN) + return@execute } - val sessionCredentials = request.execute() - saveSsoCredentials(sessionCredentials) - callback.onSuccess(sessionCredentials) - } catch (error: AuthenticationException) { - val exception = when { - error.isNetworkError -> CredentialsManagerException.Code.NO_NETWORK - else -> CredentialsManagerException.Code.SSO_EXCHANGE_FAILED + + val tokenType = storage.retrieveString(KEY_TOKEN_TYPE) ?: existingCredentials.type + validateDPoPState(tokenType)?.let { dpopError -> + callback.onFailure(dpopError) + return@execute } - callback.onFailure( - CredentialsManagerException( - exception, error + + val request = + authenticationClient.ssoExchange(existingCredentials.refreshToken) + try { + if (parameters.isNotEmpty()) { + request.addParameters(parameters) + } + val sessionCredentials = request.execute() + saveSsoCredentials(sessionCredentials) + callback.onSuccess(sessionCredentials) + } catch (error: AuthenticationException) { + val exception = when { + error.isNetworkError -> CredentialsManagerException.Code.NO_NETWORK + else -> CredentialsManagerException.Code.SSO_EXCHANGE_FAILED + } + callback.onFailure( + CredentialsManagerException( + exception, error + ) ) - ) - } catch (error: CredentialsManagerException) { - val exception = CredentialsManagerException( - CredentialsManagerException.Code.STORE_FAILED, error - ) - callback.onFailure(exception) - } catch (exception: RuntimeException) { - Log.e( - TAG, - "Caught unexpected exceptions while fetching sso token ${exception.stackTraceToString()}" - ) - callback.onFailure( - CredentialsManagerException( - CredentialsManagerException.Code.UNKNOWN_ERROR, - exception + } catch (error: CredentialsManagerException) { + val exception = CredentialsManagerException( + CredentialsManagerException.Code.STORE_FAILED, error ) - ) + callback.onFailure(exception) + } } } } @@ -799,163 +790,147 @@ public class SecureCredentialsManager @VisibleForTesting(otherwise = VisibleForT callback: Callback ) { serialExecutor.execute { - val encryptedEncoded = storage.retrieveString(KEY_CREDENTIALS) - if (encryptedEncoded.isNullOrBlank()) { - callback.onFailure(CredentialsManagerException.NO_CREDENTIALS) - return@execute - } - val encrypted = Base64.decode(encryptedEncoded, Base64.DEFAULT) - val json: String - try { - json = String(crypto.decrypt(encrypted)) - } catch (e: IncompatibleDeviceException) { - callback.onFailure( - CredentialsManagerException( - CredentialsManagerException.Code.INCOMPATIBLE_DEVICE, e + runCatchingOnExecutor(callback) { + val encryptedEncoded = storage.retrieveString(KEY_CREDENTIALS) + if (encryptedEncoded.isNullOrBlank()) { + callback.onFailure(CredentialsManagerException.NO_CREDENTIALS) + return@execute + } + val encrypted = Base64.decode(encryptedEncoded, Base64.DEFAULT) + val json: String + try { + json = String(crypto.decrypt(encrypted)) + } catch (e: IncompatibleDeviceException) { + callback.onFailure( + CredentialsManagerException( + CredentialsManagerException.Code.INCOMPATIBLE_DEVICE, e + ) ) - ) - return@execute - } catch (e: CryptoException) { - //If keys were invalidated, existing credentials will not be recoverable. - clearCredentials() - callback.onFailure( - CredentialsManagerException( - CredentialsManagerException.Code.CRYPTO_EXCEPTION, e + return@execute + } catch (e: CryptoException) { + clearCredentials() + callback.onFailure( + CredentialsManagerException( + CredentialsManagerException.Code.CRYPTO_EXCEPTION, e + ) ) + return@execute + } + val bridgeCredentials = + gson.fromJson(json, OptionalCredentials::class.java) + val credentials = Credentials( + bridgeCredentials.idToken.orEmpty(), + bridgeCredentials.accessToken.orEmpty(), + bridgeCredentials.type.orEmpty(), + bridgeCredentials.refreshToken, + bridgeCredentials.expiresAt ?: Date(), + bridgeCredentials.scope + ) + val expiresAt = credentials.expiresAt.time + val hasEmptyCredentials = + TextUtils.isEmpty(credentials.accessToken) && TextUtils.isEmpty(credentials.idToken) + if (hasEmptyCredentials) { + callback.onFailure(CredentialsManagerException.NO_CREDENTIALS) + return@execute + } + val willAccessTokenExpire = willExpire(expiresAt, minTtl.toLong()) + val scopeChanged = hasScopeChanged(credentials.scope, scope) + if (!forceRefresh && !willAccessTokenExpire && !scopeChanged) { + callback.onSuccess(credentials) + return@execute + } + if (credentials.refreshToken == null) { + callback.onFailure(CredentialsManagerException.NO_REFRESH_TOKEN) + return@execute + } + val tokenType = storage.retrieveString(KEY_TOKEN_TYPE) ?: credentials.type + validateDPoPState(tokenType)?.let { dpopError -> + callback.onFailure(dpopError) + return@execute + } + Log.d(TAG, "Credentials have expired. Renewing them now...") + val request = authenticationClient.renewAuth( + credentials.refreshToken ) - return@execute - } - val bridgeCredentials = gson.fromJson(json, OptionalCredentials::class.java)/* OPTIONAL CREDENTIALS - * This bridge is required to prevent users from being logged out when - * migrating from Credentials with optional Access Token and ID token - */ - val credentials = Credentials( - bridgeCredentials.idToken.orEmpty(), - bridgeCredentials.accessToken.orEmpty(), - bridgeCredentials.type.orEmpty(), - bridgeCredentials.refreshToken, - bridgeCredentials.expiresAt ?: Date(), - bridgeCredentials.scope - ) - val expiresAt = credentials.expiresAt.time - val hasEmptyCredentials = - TextUtils.isEmpty(credentials.accessToken) && TextUtils.isEmpty(credentials.idToken) - if (hasEmptyCredentials) { - callback.onFailure(CredentialsManagerException.NO_CREDENTIALS) - return@execute - } - val willAccessTokenExpire = willExpire(expiresAt, minTtl.toLong()) - val scopeChanged = hasScopeChanged(credentials.scope, scope) - if (!forceRefresh && !willAccessTokenExpire && !scopeChanged) { - callback.onSuccess(credentials) - return@execute - } - if (credentials.refreshToken == null) { - callback.onFailure(CredentialsManagerException.NO_REFRESH_TOKEN) - return@execute - } - val tokenType = storage.retrieveString(KEY_TOKEN_TYPE) ?: credentials.type - validateDPoPState(tokenType)?.let { dpopError -> - callback.onFailure(dpopError) - return@execute - } - Log.d(TAG, "Credentials have expired. Renewing them now...") - val request = authenticationClient.renewAuth( - credentials.refreshToken - ) - request.addParameters(parameters) - if (scope != null) { - request.addParameter("scope", scope) - } + request.addParameters(parameters) + if (scope != null) { + request.addParameter("scope", scope) + } - for (header in headers) { - request.addHeader(header.key, header.value) - } + for (header in headers) { + request.addHeader(header.key, header.value) + } - val freshCredentials: Credentials - try { - val fresh = request.execute() - val expiresAt = fresh.expiresAt.time - val willAccessTokenExpire = willExpire(expiresAt, minTtl.toLong()) - if (willAccessTokenExpire) { - val tokenLifetime = (expiresAt - currentTimeInMillis - minTtl * 1000) / -1000 - val wrongTtlException = CredentialsManagerException( - CredentialsManagerException.Code.LARGE_MIN_TTL, String.format( - Locale.getDefault(), - "The lifetime of the renewed Access Token (%d) is less than the minTTL requested (%d). Increase the 'Token Expiration' setting of your Auth0 API in the dashboard, or request a lower minTTL.", - tokenLifetime, - minTtl + val freshCredentials: Credentials + try { + val fresh = request.execute() + val expiresAt = fresh.expiresAt.time + val willAccessTokenExpire = willExpire(expiresAt, minTtl.toLong()) + if (willAccessTokenExpire) { + val tokenLifetime = + (expiresAt - currentTimeInMillis - minTtl * 1000) / -1000 + val wrongTtlException = CredentialsManagerException( + CredentialsManagerException.Code.LARGE_MIN_TTL, String.format( + Locale.getDefault(), + "The lifetime of the renewed Access Token (%d) is less than the minTTL requested (%d). Increase the 'Token Expiration' setting of your Auth0 API in the dashboard, or request a lower minTTL.", + tokenLifetime, + minTtl + ) ) + callback.onFailure(wrongTtlException) + return@execute + } + + val updatedRefreshToken = + if (TextUtils.isEmpty(fresh.refreshToken)) credentials.refreshToken else fresh.refreshToken + freshCredentials = Credentials( + fresh.idToken, + fresh.accessToken, + fresh.type, + updatedRefreshToken, + fresh.expiresAt, + fresh.scope ) - callback.onFailure(wrongTtlException) - return@execute - } + } catch (error: AuthenticationException) { + if (error.isMultifactorRequired) { + callback.onFailure( + CredentialsManagerException( + CredentialsManagerException.Code.MFA_REQUIRED, + error.message + ?: "Multi-factor authentication is required to complete the credential renewal.", + error, + error.mfaRequiredErrorPayload + ) + ) + return@execute + } + val exception = when { + error.isRefreshTokenDeleted || error.isInvalidRefreshToken -> CredentialsManagerException.Code.RENEW_FAILED - //non-empty refresh token for refresh token rotation scenarios - val updatedRefreshToken = - if (TextUtils.isEmpty(fresh.refreshToken)) credentials.refreshToken else fresh.refreshToken - freshCredentials = Credentials( - fresh.idToken, - fresh.accessToken, - fresh.type, - updatedRefreshToken, - fresh.expiresAt, - fresh.scope - ) - } catch (error: AuthenticationException) { - if (error.isMultifactorRequired) { + error.isNetworkError -> CredentialsManagerException.Code.NO_NETWORK + else -> CredentialsManagerException.Code.API_ERROR + } callback.onFailure( CredentialsManagerException( - CredentialsManagerException.Code.MFA_REQUIRED, - error.message - ?: "Multi-factor authentication is required to complete the credential renewal.", - error, - error.mfaRequiredErrorPayload + exception, error ) ) return@execute } - val exception = when { - error.isRefreshTokenDeleted || error.isInvalidRefreshToken -> CredentialsManagerException.Code.RENEW_FAILED - error.isNetworkError -> CredentialsManagerException.Code.NO_NETWORK - else -> CredentialsManagerException.Code.API_ERROR - } - callback.onFailure( - CredentialsManagerException( - exception, error - ) - ) - return@execute - } catch (exception: RuntimeException) { - /** - * Catching any unexpected runtime errors in the token renewal flow - */ - Log.e( - TAG, - "Caught unexpected exceptions for token renewal ${exception.stackTraceToString()}" - ) - callback.onFailure( - CredentialsManagerException( - CredentialsManagerException.Code.UNKNOWN_ERROR, - exception + try { + saveCredentials(freshCredentials) + callback.onSuccess(freshCredentials) + } catch (error: CredentialsManagerException) { + val exception = CredentialsManagerException( + CredentialsManagerException.Code.STORE_FAILED, error ) - ) - return@execute - } - - try { - saveCredentials(freshCredentials) - callback.onSuccess(freshCredentials) - } catch (error: CredentialsManagerException) { - val exception = CredentialsManagerException( - CredentialsManagerException.Code.STORE_FAILED, error - ) - if (error.cause is IncompatibleDeviceException || error.cause is CryptoException) { - exception.refreshedCredentials = freshCredentials + if (error.cause is IncompatibleDeviceException || error.cause is CryptoException) { + exception.refreshedCredentials = freshCredentials + } + callback.onFailure(exception) } - callback.onFailure(exception) } } } @@ -971,130 +946,140 @@ public class SecureCredentialsManager @VisibleForTesting(otherwise = VisibleForT callback: Callback ) { serialExecutor.execute { - val encryptedEncodedJson = storage.retrieveString(getAPICredentialsKey(audience, scope)) - //Check if existing api credentials are present and valid - - var apiCredentialType: String? = null - encryptedEncodedJson?.let { encryptedEncoded -> - val encrypted = Base64.decode(encryptedEncoded, Base64.DEFAULT) - val json: String = try { - String(crypto.decrypt(encrypted)) - } catch (e: IncompatibleDeviceException) { - callback.onFailure( - CredentialsManagerException( - CredentialsManagerException.Code.INCOMPATIBLE_DEVICE, - e + runCatchingOnExecutor(callback) { + val encryptedEncodedJson = + storage.retrieveString(getAPICredentialsKey(audience, scope)) + + var apiCredentialType: String? = null + encryptedEncodedJson?.let { encryptedEncoded -> + val encrypted = Base64.decode(encryptedEncoded, Base64.DEFAULT) + val json: String = try { + String(crypto.decrypt(encrypted)) + } catch (e: IncompatibleDeviceException) { + callback.onFailure( + CredentialsManagerException( + CredentialsManagerException.Code.INCOMPATIBLE_DEVICE, + e + ) ) - ) - return@execute - } catch (e: CryptoException) { - clearApiCredentials(audience, scope) - callback.onFailure( - CredentialsManagerException( - CredentialsManagerException.Code.CRYPTO_EXCEPTION, - e + return@execute + } catch (e: CryptoException) { + clearApiCredentials(audience, scope) + callback.onFailure( + CredentialsManagerException( + CredentialsManagerException.Code.CRYPTO_EXCEPTION, + e + ) ) + return@execute + } + + val apiCredentials = gson.fromJson(json, APICredentials::class.java) + apiCredentialType = apiCredentials.type + + val expiresAt = apiCredentials.expiresAt.time + val willAccessTokenExpire = willExpire(expiresAt, minTtl.toLong()) + val scopeChanged = hasScopeChanged( + apiCredentials.scope, scope, + ignoreOpenid = scope?.contains("openid") == false ) - return@execute + val hasExpired = hasExpired(apiCredentials.expiresAt.time) + if (!hasExpired && !willAccessTokenExpire && !scopeChanged) { + callback.onSuccess(apiCredentials) + return@execute + } } - val apiCredentials = gson.fromJson(json, APICredentials::class.java) - apiCredentialType = apiCredentials.type - - val expiresAt = apiCredentials.expiresAt.time - val willAccessTokenExpire = willExpire(expiresAt, minTtl.toLong()) - val scopeChanged = hasScopeChanged( - apiCredentials.scope, scope, - ignoreOpenid = scope?.contains("openid") == false - ) - val hasExpired = hasExpired(apiCredentials.expiresAt.time) - if (!hasExpired && !willAccessTokenExpire && !scopeChanged) { - callback.onSuccess(apiCredentials) + val existingCredentials: Credentials = try { + getExistingCredentials() + } catch (exception: CredentialsManagerException) { + callback.onFailure(exception) + return@execute + } + val refreshToken = existingCredentials.refreshToken + if (refreshToken == null) { + callback.onFailure(CredentialsManagerException.NO_REFRESH_TOKEN) return@execute } - } - - //Check if refresh token exists or not - val existingCredentials: Credentials = try { - getExistingCredentials() - } catch (exception: CredentialsManagerException) { - callback.onFailure(exception) - return@execute - } - val refreshToken = existingCredentials.refreshToken - if (refreshToken == null) { - callback.onFailure(CredentialsManagerException.NO_REFRESH_TOKEN) - return@execute - } - val tokenType = apiCredentialType ?: storage.retrieveString(KEY_TOKEN_TYPE) ?: existingCredentials.type - validateDPoPState(tokenType)?.let { dpopError -> - callback.onFailure(dpopError) - return@execute - } + val tokenType = + apiCredentialType ?: storage.retrieveString(KEY_TOKEN_TYPE) + ?: existingCredentials.type + validateDPoPState(tokenType)?.let { dpopError -> + callback.onFailure(dpopError) + return@execute + } - val request = authenticationClient.renewAuth(refreshToken, audience, scope) - request.addParameters(parameters) - for (header in headers) { - request.addHeader(header.key, header.value) - } + val request = authenticationClient.renewAuth(refreshToken, audience, scope) + request.addParameters(parameters) + for (header in headers) { + request.addHeader(header.key, header.value) + } - try { - val newCredentials = request.execute() - val expiresAt = newCredentials.expiresAt.time - val willAccessTokenExpire = willExpire(expiresAt, minTtl.toLong()) - if (willAccessTokenExpire) { - val tokenLifetime = (expiresAt - currentTimeInMillis - minTtl * 1000) / -1000 - val wrongTtlException = CredentialsManagerException( - CredentialsManagerException.Code.LARGE_MIN_TTL, String.format( - Locale.getDefault(), - "The lifetime of the renewed Access Token (%d) is less than the minTTL requested (%d). Increase the 'Token Expiration' setting of your Auth0 API in the dashboard, or request a lower minTTL.", - tokenLifetime, - minTtl + val newApiCredentials: APICredentials + try { + val newCredentials = request.execute() + val expiresAt = newCredentials.expiresAt.time + val willAccessTokenExpire = willExpire(expiresAt, minTtl.toLong()) + if (willAccessTokenExpire) { + val tokenLifetime = + (expiresAt - currentTimeInMillis - minTtl * 1000) / -1000 + val wrongTtlException = CredentialsManagerException( + CredentialsManagerException.Code.LARGE_MIN_TTL, String.format( + Locale.getDefault(), + "The lifetime of the renewed Access Token (%d) is less than the minTTL requested (%d). Increase the 'Token Expiration' setting of your Auth0 API in the dashboard, or request a lower minTTL.", + tokenLifetime, + minTtl + ) ) - ) - callback.onFailure(wrongTtlException) - return@execute - } + callback.onFailure(wrongTtlException) + return@execute + } - // non-empty refresh token for refresh token rotation scenarios - val updatedRefreshToken = - if (TextUtils.isEmpty(newCredentials.refreshToken)) refreshToken else newCredentials.refreshToken - val newApiCredentials = newCredentials.toAPICredentials() - saveCredentials( - existingCredentials.copy( - refreshToken = updatedRefreshToken, - idToken = newCredentials.idToken + val updatedRefreshToken = + if (TextUtils.isEmpty(newCredentials.refreshToken)) refreshToken else newCredentials.refreshToken + newApiCredentials = newCredentials.toAPICredentials() + saveCredentials( + existingCredentials.copy( + refreshToken = updatedRefreshToken, + idToken = newCredentials.idToken + ) ) - ) - saveApiCredentials(newApiCredentials, audience, scope) - callback.onSuccess(newApiCredentials) - - } catch (error: AuthenticationException) { - if (error.isMultifactorRequired) { + saveApiCredentials(newApiCredentials, audience, scope) + } catch (error: AuthenticationException) { + if (error.isMultifactorRequired) { + callback.onFailure( + CredentialsManagerException( + CredentialsManagerException.Code.MFA_REQUIRED, + error.message + ?: "Multi-factor authentication is required to complete the credential renewal.", + error, + error.mfaRequiredErrorPayload + ) + ) + return@execute + } + val exception = when { + error.isRefreshTokenDeleted || error.isInvalidRefreshToken -> CredentialsManagerException.Code.RENEW_FAILED + error.isNetworkError -> CredentialsManagerException.Code.NO_NETWORK + else -> CredentialsManagerException.Code.API_ERROR + } callback.onFailure( CredentialsManagerException( - CredentialsManagerException.Code.MFA_REQUIRED, - error.message - ?: "Multi-factor authentication is required to complete the credential renewal.", - error, - error.mfaRequiredErrorPayload + exception, error ) ) return@execute - } - val exception = when { - error.isRefreshTokenDeleted || error.isInvalidRefreshToken -> CredentialsManagerException.Code.RENEW_FAILED - error.isNetworkError -> CredentialsManagerException.Code.NO_NETWORK - else -> CredentialsManagerException.Code.API_ERROR - } - callback.onFailure( - CredentialsManagerException( - exception, error + } catch (error: CredentialsManagerException) { + val storeException = CredentialsManagerException( + CredentialsManagerException.Code.STORE_FAILED, error ) - ) - } + callback.onFailure(storeException) + return@execute + } + callback.onSuccess(newApiCredentials) + } } } diff --git a/auth0/src/test/java/com/auth0/android/authentication/storage/CredentialsManagerTest.kt b/auth0/src/test/java/com/auth0/android/authentication/storage/CredentialsManagerTest.kt index 42668a9ad..c0e40a29d 100644 --- a/auth0/src/test/java/com/auth0/android/authentication/storage/CredentialsManagerTest.kt +++ b/auth0/src/test/java/com/auth0/android/authentication/storage/CredentialsManagerTest.kt @@ -2445,6 +2445,84 @@ public class CredentialsManagerTest { MatcherAssert.assertThat(exception, Is.`is`(CredentialsManagerException.DPOP_KEY_MISSING)) } + // Verifies the outer runCatchingOnExecutor safety net in getCredentials. + // The IllegalStateException is thrown from storage.retrieveString() which is outside any inner + // try/catch block — only the outer catch(Throwable) in runCatchingOnExecutor can handle it. + @Test + public fun shouldCatchUnexpectedExceptionFromStorageOnGetCredentials() { + val error = IllegalStateException("storage corrupted") + Mockito.`when`(storage.retrieveString("com.auth0.access_token")).thenThrow(error) + manager.getCredentials(callback) + verify(callback).onFailure(exceptionCaptor.capture()) + val exception = exceptionCaptor.firstValue + MatcherAssert.assertThat(exception, Is.`is`(Matchers.notNullValue())) + MatcherAssert.assertThat(exception, Is.`is`(CredentialsManagerException.UNKNOWN_ERROR)) + MatcherAssert.assertThat(exception.cause, Is.`is`(error)) + } + + // Same as above but via the coroutine path (awaitCredentials -> suspendCancellableCoroutine). + // Verifies that the outer catch routes the error through callback.onFailure which then + // resumes the continuation with the exception. + @Test + @ExperimentalCoroutinesApi + public fun shouldCatchUnexpectedExceptionFromStorageOnAwaitCredentials(): Unit = runTest { + val error = IllegalStateException("storage corrupted") + Mockito.`when`(storage.retrieveString("com.auth0.access_token")).thenThrow(error) + val exception = assertThrows(CredentialsManagerException::class.java) { + runBlocking { manager.awaitCredentials() } + } + MatcherAssert.assertThat(exception, Is.`is`(Matchers.notNullValue())) + MatcherAssert.assertThat(exception, Is.`is`(CredentialsManagerException.UNKNOWN_ERROR)) + MatcherAssert.assertThat(exception.cause, Is.`is`(error)) + } + + // Verifies the outer runCatchingOnExecutor safety net in getSsoCredentials. + // The exception is thrown from storage before any inner try/catch is reached. + @Test + public fun shouldCatchUnexpectedExceptionFromStorageOnGetSsoCredentials() { + val error = IllegalStateException("storage corrupted") + Mockito.`when`(storage.retrieveString("com.auth0.refresh_token")).thenThrow(error) + manager.getSsoCredentials(ssoCallback) + verify(ssoCallback).onFailure(exceptionCaptor.capture()) + val exception = exceptionCaptor.firstValue + MatcherAssert.assertThat(exception, Is.`is`(Matchers.notNullValue())) + MatcherAssert.assertThat(exception, Is.`is`(CredentialsManagerException.UNKNOWN_ERROR)) + MatcherAssert.assertThat(exception.cause, Is.`is`(error)) + } + + // Verifies the outer runCatchingOnExecutor safety net in getApiCredentials. + // The exception is thrown from storage before any inner try/catch is reached. + @Test + public fun shouldCatchUnexpectedExceptionFromStorageOnGetApiCredentials() { + val error = IllegalStateException("storage corrupted") + Mockito.`when`(storage.retrieveString("audience")).thenThrow(error) + manager.getApiCredentials("audience", callback = apiCredentialsCallback) + verify(apiCredentialsCallback).onFailure(exceptionCaptor.capture()) + val exception = exceptionCaptor.firstValue + MatcherAssert.assertThat(exception, Is.`is`(Matchers.notNullValue())) + MatcherAssert.assertThat(exception, Is.`is`(CredentialsManagerException.UNKNOWN_ERROR)) + MatcherAssert.assertThat(exception.cause, Is.`is`(error)) + } + + // Verifies the outer runCatchingOnExecutor safety net during API credential renewal. + // NullPointerException from request.execute() is not an AuthenticationException, so it + // escapes the inner catch(AuthenticationException) block. Only the outer catch(Throwable) + // in runCatchingOnExecutor handles it. + @Test + public fun shouldCatchUnexpectedExceptionDuringApiCredentialRenewal() { + val error = NullPointerException("unexpected null") + Mockito.`when`(storage.retrieveString("audience")).thenReturn(null) + Mockito.`when`(storage.retrieveString("com.auth0.refresh_token")).thenReturn("refreshToken") + Mockito.`when`(client.renewAuth("refreshToken", "audience", null)).thenReturn(request) + Mockito.`when`(request.execute()).thenThrow(error) + manager.getApiCredentials("audience", callback = apiCredentialsCallback) + verify(apiCredentialsCallback).onFailure(exceptionCaptor.capture()) + val exception = exceptionCaptor.firstValue + MatcherAssert.assertThat(exception, Is.`is`(Matchers.notNullValue())) + MatcherAssert.assertThat(exception, Is.`is`(CredentialsManagerException.UNKNOWN_ERROR)) + MatcherAssert.assertThat(exception.cause, Is.`is`(error)) + } + @After public fun tearDown() { DPoPUtil.keyStore = DPoPKeyStore() diff --git a/auth0/src/test/java/com/auth0/android/authentication/storage/SecureCredentialsManagerTest.kt b/auth0/src/test/java/com/auth0/android/authentication/storage/SecureCredentialsManagerTest.kt index 10d495030..fde98151c 100644 --- a/auth0/src/test/java/com/auth0/android/authentication/storage/SecureCredentialsManagerTest.kt +++ b/auth0/src/test/java/com/auth0/android/authentication/storage/SecureCredentialsManagerTest.kt @@ -3857,6 +3857,75 @@ public class SecureCredentialsManagerTest { MatcherAssert.assertThat(exception, Is.`is`(CredentialsManagerException.DPOP_KEY_MISSING)) } + // Verifies the outer runCatchingOnExecutor safety net in continueGetCredentials. + // The IllegalStateException is thrown from storage.retrieveString() which is outside any inner + // try/catch block — only the outer catch(Throwable) in runCatchingOnExecutor can handle it. + @Test + public fun shouldCatchUnexpectedExceptionFromStorageOnGetCredentials() { + val error = IllegalStateException("storage corrupted") + Mockito.`when`(storage.retrieveString("com.auth0.credentials")).thenThrow(error) + manager.continueGetCredentials(null, 0, emptyMap(), emptyMap(), false, callback) + verify(callback).onFailure(exceptionCaptor.capture()) + val exception = exceptionCaptor.firstValue + MatcherAssert.assertThat(exception, Is.`is`(Matchers.notNullValue())) + MatcherAssert.assertThat(exception, Is.`is`(CredentialsManagerException.UNKNOWN_ERROR)) + MatcherAssert.assertThat(exception.cause, Is.`is`(error)) + } + + // Verifies the outer runCatchingOnExecutor safety net in getSsoCredentials. + // The exception is thrown from getExistingCredentials() → storage.retrieveString() + // before any inner try/catch is reached. + @Test + public fun shouldCatchUnexpectedExceptionFromStorageOnGetSsoCredentials() { + val error = IllegalStateException("storage corrupted") + Mockito.`when`(storage.retrieveString("com.auth0.credentials")).thenThrow(error) + manager.getSsoCredentials(ssoCallback) + verify(ssoCallback).onFailure(exceptionCaptor.capture()) + val exception = exceptionCaptor.firstValue + MatcherAssert.assertThat(exception, Is.`is`(Matchers.notNullValue())) + MatcherAssert.assertThat(exception, Is.`is`(CredentialsManagerException.UNKNOWN_ERROR)) + MatcherAssert.assertThat(exception.cause, Is.`is`(error)) + } + + // Verifies the outer runCatchingOnExecutor safety net in continueGetApiCredentials. + // The exception is thrown from storage before any inner try/catch is reached. + @Test + public fun shouldCatchUnexpectedExceptionFromStorageOnGetApiCredentials() { + val error = IllegalStateException("storage corrupted") + Mockito.`when`(storage.retrieveString("audience")).thenThrow(error) + manager.continueGetApiCredentials("audience", null, 0, emptyMap(), emptyMap(), apiCredentialsCallback) + verify(apiCredentialsCallback).onFailure(exceptionCaptor.capture()) + val exception = exceptionCaptor.firstValue + MatcherAssert.assertThat(exception, Is.`is`(Matchers.notNullValue())) + MatcherAssert.assertThat(exception, Is.`is`(CredentialsManagerException.UNKNOWN_ERROR)) + MatcherAssert.assertThat(exception.cause, Is.`is`(error)) + } + + // Verifies the outer runCatchingOnExecutor safety net during API credential renewal. + // NullPointerException from request.execute() is not an AuthenticationException, so it + // escapes the inner catch(AuthenticationException) block. Only the outer catch(Throwable) + // in runCatchingOnExecutor handles it. + @Test + public fun shouldCatchUnexpectedExceptionDuringApiCredentialRenewal() { + val error = NullPointerException("unexpected null") + Mockito.`when`(storage.retrieveString("audience")).thenReturn(null) + insertTestCredentials( + hasIdToken = true, + hasAccessToken = true, + hasRefreshToken = true, + willExpireAt = Date(CredentialsMock.ONE_HOUR_AHEAD_MS), + scope = "scope" + ) + Mockito.`when`(client.renewAuth("refreshToken", "audience", null)).thenReturn(request) + Mockito.`when`(request.execute()).thenThrow(error) + manager.continueGetApiCredentials("audience", null, 0, emptyMap(), emptyMap(), apiCredentialsCallback) + verify(apiCredentialsCallback).onFailure(exceptionCaptor.capture()) + val exception = exceptionCaptor.firstValue + MatcherAssert.assertThat(exception, Is.`is`(Matchers.notNullValue())) + MatcherAssert.assertThat(exception, Is.`is`(CredentialsManagerException.UNKNOWN_ERROR)) + MatcherAssert.assertThat(exception.cause, Is.`is`(error)) + } + @After public fun tearDown() { DPoPUtil.keyStore = DPoPKeyStore() From 0bd5b9f8ffb90fe4f1fd9825b7798643b8ce37f6 Mon Sep 17 00:00:00 2001 From: Prince Mathew <17837162+pmathew92@users.noreply.github.com> Date: Mon, 25 May 2026 12:34:42 +0530 Subject: [PATCH 33/60] feat : Add support to pass type parameter to the MyAccount list authentication method API (#974) --- EXAMPLES.md | 56 ++++++- .../myaccount/AuthenticationMethodType.kt | 16 ++ .../android/myaccount/MyAccountAPIClient.kt | 111 +++++++++---- .../myaccount/MyAccountAPIClientTest.kt | 154 ++++++++++++++++++ 4 files changed, 301 insertions(+), 36 deletions(-) create mode 100644 auth0/src/main/java/com/auth0/android/myaccount/AuthenticationMethodType.kt diff --git a/EXAMPLES.md b/EXAMPLES.md index 52ea9eae9..d26eb1bdf 100644 --- a/EXAMPLES.md +++ b/EXAMPLES.md @@ -1845,6 +1845,30 @@ Use the Auth0 My Account API to manage the current user's account. To call the My Account API, you need an access token issued specifically for this API, including any required scopes for the operations you want to perform. See [API credentials [EA]](#api-credentials-ea) to learn how to obtain one. +```kotlin +val client = MyAccountAPIClient(auth0, accessToken) +``` + +#### Using DPoP + +If your application uses [DPoP (Demonstrating Proof of Possession)](https://auth0.com/docs/get-started/authentication-and-authorization-flow/call-your-api-using-the-authorization-code-flow-with-dpop), you can enable it on the My Account API client: + +```kotlin +val client = MyAccountAPIClient(auth0, accessToken).useDPoP(context) +``` + +When DPoP is enabled, the client will automatically: +- Use the `DPoP` authorization scheme instead of `Bearer` +- Include a DPoP proof header on every request + +
+ Using Java + +```java +MyAccountAPIClient client = new MyAccountAPIClient(auth0, accessToken).useDPoP(context); +``` +
+ ### Enroll a new passkey **Scopes required:** `create:me:authentication_methods` @@ -2052,7 +2076,7 @@ myAccountClient.getFactors() ### Get All Enrolled Authentication Methods **Scopes required:** `read:me:authentication_methods` -Retrieves a detailed list of all the authentication methods that the current user has already enrolled in. +Retrieves a detailed list of all the authentication methods that the current user has already enrolled in. You can optionally filter the results by type using `AuthenticationMethodType`. **Prerequisites:** @@ -2060,10 +2084,20 @@ Retrieves a detailed list of all the authentication methods that the current use The user must have one or more authentication methods already enrolled. ```kotlin +// Get all authentication methods myAccountClient.getAuthenticationMethods() .start(object : Callback, MyAccountException> { - override fun onSuccess(result: AuthenticationMethods) { - // List of enrolled methods in result.authenticationMethods + override fun onSuccess(result: List) { + // List of enrolled methods + } + override fun onFailure(error: MyAccountException) { } + }) + +// Get authentication methods filtered by type +myAccountClient.getAuthenticationMethods(AuthenticationMethodType.PASSKEY) + .start(object : Callback, MyAccountException> { + override fun onSuccess(result: List) { + // List of enrolled passkey methods only } override fun onFailure(error: MyAccountException) { } }) @@ -2072,11 +2106,23 @@ myAccountClient.getAuthenticationMethods() Using Java ```java +// Get all authentication methods myAccountClient.getAuthenticationMethods() .start(new Callback, MyAccountException>() { @Override - public void onSuccess(AuthenticationMethods result) { - // List of enrolled methods in result.getAuthenticationMethods() + public void onSuccess(List result) { + // List of enrolled methods + } + @Override + public void onFailure(@NonNull MyAccountException error) { } + }); + +// Get authentication methods filtered by type +myAccountClient.getAuthenticationMethods(AuthenticationMethodType.PASSKEY) + .start(new Callback, MyAccountException>() { + @Override + public void onSuccess(List result) { + // List of enrolled passkey methods only } @Override public void onFailure(@NonNull MyAccountException error) { } diff --git a/auth0/src/main/java/com/auth0/android/myaccount/AuthenticationMethodType.kt b/auth0/src/main/java/com/auth0/android/myaccount/AuthenticationMethodType.kt new file mode 100644 index 000000000..8f8002ba6 --- /dev/null +++ b/auth0/src/main/java/com/auth0/android/myaccount/AuthenticationMethodType.kt @@ -0,0 +1,16 @@ +package com.auth0.android.myaccount + +/** + * Represents the types of authentication methods supported by the My Account API. + */ +public enum class AuthenticationMethodType(public val type: String) { + PASSWORD("password"), + PASSKEY("passkey"), + TOTP("totp"), + PHONE("phone"), + EMAIL("email"), + PUSH("push-notification"), + RECOVERY_CODE("recovery-code"), + WEBAUTHN_PLATFORM("webauthn-platform"), + WEBAUTHN_ROAMING("webauthn-roaming") +} \ No newline at end of file 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 0a71b1f66..14ad4b4c6 100644 --- a/auth0/src/main/java/com/auth0/android/myaccount/MyAccountAPIClient.kt +++ b/auth0/src/main/java/com/auth0/android/myaccount/MyAccountAPIClient.kt @@ -1,10 +1,13 @@ package com.auth0.android.myaccount +import android.content.Context import androidx.annotation.VisibleForTesting import com.auth0.android.Auth0 import com.auth0.android.Auth0Exception import com.auth0.android.NetworkErrorException import com.auth0.android.authentication.ParameterBuilder +import com.auth0.android.dpop.DPoP +import com.auth0.android.dpop.SenderConstraining import com.auth0.android.request.ErrorAdapter import com.auth0.android.request.JsonAdapter import com.auth0.android.request.PublicKeyCredentials @@ -24,7 +27,6 @@ import com.auth0.android.result.PasskeyEnrollmentChallenge import com.auth0.android.result.PasskeyRegistrationChallenge import com.auth0.android.result.RecoveryCodeEnrollmentChallenge import com.auth0.android.result.TotpEnrollmentChallenge - import com.google.gson.Gson import okhttp3.HttpUrl import okhttp3.HttpUrl.Companion.toHttpUrl @@ -52,7 +54,12 @@ public class MyAccountAPIClient @VisibleForTesting(otherwise = VisibleForTesting private val accessToken: String, private val factory: RequestFactory, private val gson: Gson -) { +) : SenderConstraining { + + private var dPoP: DPoP? = null + + private val authorizationHeader: String + get() = if (dPoP != null) "DPoP $accessToken" else "Bearer $accessToken" /** * Creates a new MyAccountAPI client instance. @@ -75,6 +82,25 @@ public class MyAccountAPIClient @VisibleForTesting(otherwise = VisibleForTesting GsonProvider.gson ) + /** + * Enable DPoP (Demonstrating Proof of Possession) for this client. + * + * When enabled, requests will include a DPoP proof header and the Authorization header + * will use the "DPoP" scheme instead of "Bearer". + * + * Example usage: + * ```kotlin + * val client = MyAccountAPIClient(auth0, accessToken).useDPoP(context) + * ``` + * + * @param context the Android context + * @return this client instance for chaining + */ + override fun useDPoP(context: Context): MyAccountAPIClient { + dPoP = DPoP(context) + return this + } + /** * Requests a challenge for enrolling a new passkey. This is the first part of the enrollment flow. @@ -171,9 +197,9 @@ public class MyAccountAPIClient @VisibleForTesting(otherwise = VisibleForTesting ) } } - return factory.post(url.toString(), passkeyEnrollmentAdapter) + return factory.post(url.toString(), passkeyEnrollmentAdapter, dPoP) .addParameters(params) - .addHeader(AUTHORIZATION_KEY, "Bearer $accessToken") + .addHeader(AUTHORIZATION_KEY, authorizationHeader) } /** @@ -239,11 +265,12 @@ public class MyAccountAPIClient @VisibleForTesting(otherwise = VisibleForTesting return factory.post( url.toString(), - GsonAdapter(PasskeyAuthenticationMethod::class.java, gson) + GsonAdapter(PasskeyAuthenticationMethod::class.java, gson), + dPoP ) .addParameters(params) .addParameter(AUTHN_RESPONSE_KEY, authnResponse) - .addHeader(AUTHORIZATION_KEY, "Bearer $accessToken") + .addHeader(AUTHORIZATION_KEY, authorizationHeader) } @@ -256,6 +283,9 @@ public class MyAccountAPIClient @VisibleForTesting(otherwise = VisibleForTesting * [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. * + * ## Scopes Required + * + * `read:me:authentication_methods` * * ## Usage * @@ -263,7 +293,7 @@ public class MyAccountAPIClient @VisibleForTesting(otherwise = VisibleForTesting * val auth0 = Auth0.getInstance("YOUR_CLIENT_ID", "YOUR_DOMAIN") * val apiClient = MyAccountAPIClient(auth0, accessToken) * - * + * // Get all authentication methods * apiClient.getAuthenticationMethods() * .start(object : Callback, MyAccountException> { * override fun onSuccess(result: List) { @@ -274,11 +304,30 @@ public class MyAccountAPIClient @VisibleForTesting(otherwise = VisibleForTesting * Log.e("MyApp", "Failed with: ${error.message}") * } * }) + * + * // Get authentication methods filtered by type + * apiClient.getAuthenticationMethods(AuthenticationMethodType.PASSKEY) + * .start(object : Callback, MyAccountException> { + * override fun onSuccess(result: List) { + * Log.d("MyApp", "Passkey methods: $result") + * } + * + * override fun onFailure(error: MyAccountException) { + * Log.e("MyApp", "Failed with: ${error.message}") + * } + * }) * ``` * + * @param type Optional filter to retrieve only authentication methods of a specific type. + * @return A request to get the list of authentication methods. + * */ - public fun getAuthenticationMethods(): Request, MyAccountException> { - val url = getDomainUrlBuilder().addPathSegment(AUTHENTICATION_METHODS).build() + @JvmOverloads + public fun getAuthenticationMethods(type: AuthenticationMethodType? = null): Request, MyAccountException> { + val url = getDomainUrlBuilder().apply { + addPathSegment(AUTHENTICATION_METHODS) + type?.let { addQueryParameter(TYPE_KEY, it.type) } + }.build() val listAdapter = object : JsonAdapter> { override fun fromJson( @@ -289,8 +338,8 @@ public class MyAccountAPIClient @VisibleForTesting(otherwise = VisibleForTesting return container.authenticationMethods } } - return factory.get(url.toString(), listAdapter) - .addHeader(AUTHORIZATION_KEY, "Bearer $accessToken") + return factory.get(url.toString(), listAdapter, dPoP) + .addHeader(AUTHORIZATION_KEY, authorizationHeader) } @@ -331,8 +380,8 @@ public class MyAccountAPIClient @VisibleForTesting(otherwise = VisibleForTesting .addPathSegment(AUTHENTICATION_METHODS) .addPathSegment(authenticationMethodId) .build() - return factory.get(url.toString(), GsonAdapter(AuthenticationMethod::class.java, gson)) - .addHeader(AUTHORIZATION_KEY, "Bearer $accessToken") + return factory.get(url.toString(), GsonAdapter(AuthenticationMethod::class.java, gson), dPoP) + .addHeader(AUTHORIZATION_KEY, authorizationHeader) } /** @@ -390,9 +439,9 @@ public class MyAccountAPIClient @VisibleForTesting(otherwise = VisibleForTesting } }.asDictionary() - return factory.patch(url.toString(), GsonAdapter(AuthenticationMethod::class.java, gson)) + return factory.patch(url.toString(), GsonAdapter(AuthenticationMethod::class.java, gson), dPoP) .addParameters(params) - .addHeader(AUTHORIZATION_KEY, "Bearer $accessToken") + .addHeader(AUTHORIZATION_KEY, authorizationHeader) } @@ -438,8 +487,8 @@ public class MyAccountAPIClient @VisibleForTesting(otherwise = VisibleForTesting val voidAdapter = object : JsonAdapter { override fun fromJson(reader: Reader, metadata: Map): Void? = null } - return factory.delete(url.toString(), voidAdapter) - .addHeader(AUTHORIZATION_KEY, "Bearer $accessToken") + return factory.delete(url.toString(), voidAdapter, dPoP) + .addHeader(AUTHORIZATION_KEY, authorizationHeader) } /** @@ -475,8 +524,8 @@ public class MyAccountAPIClient @VisibleForTesting(otherwise = VisibleForTesting return container.factors } } - return factory.get(url.toString(), listAdapter) - .addHeader(AUTHORIZATION_KEY, "Bearer $accessToken") + return factory.get(url.toString(), listAdapter, dPoP) + .addHeader(AUTHORIZATION_KEY, authorizationHeader) } /** @@ -579,9 +628,9 @@ public class MyAccountAPIClient @VisibleForTesting(otherwise = VisibleForTesting val params = ParameterBuilder.newBuilder().set(TYPE_KEY, "totp").asDictionary() val url = getDomainUrlBuilder().addPathSegment(AUTHENTICATION_METHODS).build() val adapter = GsonAdapter(TotpEnrollmentChallenge::class.java, gson) - return factory.post(url.toString(), adapter) + return factory.post(url.toString(), adapter, dPoP) .addParameters(params) - .addHeader(AUTHORIZATION_KEY, "Bearer $accessToken") + .addHeader(AUTHORIZATION_KEY, authorizationHeader) } /** @@ -612,9 +661,9 @@ public class MyAccountAPIClient @VisibleForTesting(otherwise = VisibleForTesting val url = getDomainUrlBuilder().addPathSegment(AUTHENTICATION_METHODS).build() // The response structure for push notification challenge is the same as TOTP (contains barcode_uri) val adapter = GsonAdapter(TotpEnrollmentChallenge::class.java, gson) - return factory.post(url.toString(), adapter) + return factory.post(url.toString(), adapter, dPoP) .addParameters(params) - .addHeader(AUTHORIZATION_KEY, "Bearer $accessToken") + .addHeader(AUTHORIZATION_KEY, authorizationHeader) } /** @@ -644,9 +693,9 @@ public class MyAccountAPIClient @VisibleForTesting(otherwise = VisibleForTesting val params = ParameterBuilder.newBuilder().set(TYPE_KEY, "recovery-code").asDictionary() val url = getDomainUrlBuilder().addPathSegment(AUTHENTICATION_METHODS).build() val adapter = GsonAdapter(RecoveryCodeEnrollmentChallenge::class.java, gson) - return factory.post(url.toString(), adapter) + return factory.post(url.toString(), adapter, dPoP) .addParameters(params) - .addHeader(AUTHORIZATION_KEY, "Bearer $accessToken") + .addHeader(AUTHORIZATION_KEY, authorizationHeader) } /** @@ -687,9 +736,9 @@ public class MyAccountAPIClient @VisibleForTesting(otherwise = VisibleForTesting .addPathSegment(VERIFY) .build() val params = mapOf("otp_code" to otpCode, AUTH_SESSION_KEY to authSession) - return factory.post(url.toString(), GsonAdapter(AuthenticationMethod::class.java, gson)) + return factory.post(url.toString(), GsonAdapter(AuthenticationMethod::class.java, gson), dPoP) .addParameters(params) - .addHeader(AUTHORIZATION_KEY, "Bearer $accessToken") + .addHeader(AUTHORIZATION_KEY, authorizationHeader) } /** @@ -727,9 +776,9 @@ public class MyAccountAPIClient @VisibleForTesting(otherwise = VisibleForTesting .addPathSegment(VERIFY) .build() val params = mapOf(AUTH_SESSION_KEY to authSession) - return factory.post(url.toString(), GsonAdapter(AuthenticationMethod::class.java, gson)) + return factory.post(url.toString(), GsonAdapter(AuthenticationMethod::class.java, gson), dPoP) .addParameters(params) - .addHeader(AUTHORIZATION_KEY, "Bearer $accessToken") + .addHeader(AUTHORIZATION_KEY, authorizationHeader) } // WebAuthn methods are private. @@ -790,9 +839,9 @@ public class MyAccountAPIClient @VisibleForTesting(otherwise = VisibleForTesting private fun buildEnrollmentRequest(params: Map): Request { val url = getDomainUrlBuilder().addPathSegment(AUTHENTICATION_METHODS).build() - return factory.post(url.toString(), GsonAdapter(EnrollmentChallenge::class.java, gson)) + return factory.post(url.toString(), GsonAdapter(EnrollmentChallenge::class.java, gson), dPoP) .addParameters(params) - .addHeader(AUTHORIZATION_KEY, "Bearer $accessToken") + .addHeader(AUTHORIZATION_KEY, authorizationHeader) } private fun getDomainUrlBuilder(): HttpUrl.Builder { 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 2d4beb419..76a095428 100644 --- a/auth0/src/test/java/com/auth0/android/myaccount/MyAccountAPIClientTest.kt +++ b/auth0/src/test/java/com/auth0/android/myaccount/MyAccountAPIClientTest.kt @@ -1,6 +1,11 @@ package com.auth0.android.myaccount +import android.content.Context import com.auth0.android.Auth0 +import com.auth0.android.dpop.DPoPKeyStore +import com.auth0.android.dpop.DPoPUtil +import com.auth0.android.dpop.FakeECPrivateKey +import com.auth0.android.dpop.FakeECPublicKey import com.auth0.android.request.PublicKeyCredentials import com.auth0.android.request.Response import com.auth0.android.result.AuthenticationMethod @@ -18,6 +23,7 @@ import com.google.gson.Gson import com.google.gson.GsonBuilder import com.google.gson.reflect.TypeToken import com.nhaarman.mockitokotlin2.mock +import com.nhaarman.mockitokotlin2.whenever import okhttp3.mockwebserver.RecordedRequest import org.hamcrest.MatcherAssert.assertThat import org.hamcrest.Matchers @@ -36,11 +42,17 @@ public class MyAccountAPIClientTest { private lateinit var client: MyAccountAPIClient private lateinit var gson: Gson private lateinit var mockAPI: MyAccountAPIMockServer + private lateinit var mockKeyStore: DPoPKeyStore + private lateinit var mockContext: Context @Before public fun setUp() { mockAPI = MyAccountAPIMockServer() MockitoAnnotations.openMocks(this) + mockKeyStore = mock() + mockContext = mock() + whenever(mockContext.applicationContext).thenReturn(mockContext) + DPoPUtil.keyStore = mockKeyStore gson = GsonBuilder().serializeNulls().create() client = MyAccountAPIClient(auth0, ACCESS_TOKEN) } @@ -345,6 +357,53 @@ public class MyAccountAPIClientTest { assertThat(request.method, Matchers.equalTo("GET")) } + @Test + public fun `getAuthenticationMethods should include type query parameter when specified`() { + val callback = MockMyAccountCallback>() + client.getAuthenticationMethods(AuthenticationMethodType.PASSKEY).start(callback) + + val request = mockAPI.takeRequest() + assertThat(request.path, Matchers.equalTo("/me/v1/authentication-methods?type=passkey")) + assertThat(request.getHeader("Authorization"), Matchers.equalTo("Bearer $ACCESS_TOKEN")) + assertThat(request.method, Matchers.equalTo("GET")) + } + + @Test + public fun `getAuthenticationMethods should not include type query parameter when null`() { + val callback = MockMyAccountCallback>() + client.getAuthenticationMethods(null).start(callback) + + val request = mockAPI.takeRequest() + assertThat(request.path, Matchers.equalTo("/me/v1/authentication-methods")) + assertThat(request.method, Matchers.equalTo("GET")) + } + + @Test + public fun `getAuthenticationMethods should include correct type value for each AuthenticationMethodType`() { + val typesToExpected = mapOf( + AuthenticationMethodType.PHONE to "phone", + AuthenticationMethodType.EMAIL to "email", + AuthenticationMethodType.TOTP to "totp", + AuthenticationMethodType.PUSH to "push-notification", + AuthenticationMethodType.RECOVERY_CODE to "recovery-code", + AuthenticationMethodType.PASSWORD to "password", + AuthenticationMethodType.WEBAUTHN_PLATFORM to "webauthn-platform", + AuthenticationMethodType.WEBAUTHN_ROAMING to "webauthn-roaming" + ) + + for ((type, expected) in typesToExpected) { + val callback = MockMyAccountCallback>() + client.getAuthenticationMethods(type).start(callback) + + val request = mockAPI.takeRequest() + assertThat( + "type=$expected should be in query", + request.path, + Matchers.equalTo("/me/v1/authentication-methods?type=$expected") + ) + } + } + @Test public fun `getAuthenticationMethodById should build correct URL and Authorization header`() { val callback = MockMyAccountCallback() @@ -485,6 +544,101 @@ public class MyAccountAPIClientTest { assertThat(body, Matchers.hasEntry("type", "push-notification" as Any)) } + // DPoP tests + + @Test + public fun `should use Bearer authorization header when DPoP is not enabled`() { + val callback = MockMyAccountCallback>() + client.getFactors().start(callback) + + val request = mockAPI.takeRequest() + assertThat(request.getHeader("Authorization"), Matchers.equalTo("Bearer $ACCESS_TOKEN")) + assertThat(request.getHeader("DPoP"), Matchers.nullValue()) + } + + @Test + public fun `should use DPoP authorization header when DPoP is enabled`() { + whenever(mockKeyStore.hasKeyPair()).thenReturn(true) + whenever(mockKeyStore.getKeyPair()).thenReturn(Pair(FakeECPrivateKey(), FakeECPublicKey())) + + val dpopClient = MyAccountAPIClient(auth0, ACCESS_TOKEN).useDPoP(mockContext) + val callback = MockMyAccountCallback>() + dpopClient.getFactors().start(callback) + + val request = mockAPI.takeRequest() + assertThat(request.getHeader("Authorization"), Matchers.equalTo("DPoP $ACCESS_TOKEN")) + assertThat(request.getHeader("DPoP"), Matchers.notNullValue()) + } + + @Test + public fun `should include DPoP proof header on POST requests when DPoP is enabled`() { + whenever(mockKeyStore.hasKeyPair()).thenReturn(true) + whenever(mockKeyStore.getKeyPair()).thenReturn(Pair(FakeECPrivateKey(), FakeECPublicKey())) + + val dpopClient = MyAccountAPIClient(auth0, ACCESS_TOKEN).useDPoP(mockContext) + val callback = MockMyAccountCallback() + dpopClient.enrollEmail("test@example.com").start(callback) + + val request = mockAPI.takeRequest() + assertThat(request.getHeader("Authorization"), Matchers.equalTo("DPoP $ACCESS_TOKEN")) + assertThat(request.getHeader("DPoP"), Matchers.notNullValue()) + assertThat(request.method, Matchers.equalTo("POST")) + } + + @Test + public fun `should not include DPoP proof header when DPoP is not enabled`() { + val callback = MockMyAccountCallback() + client.enrollEmail("test@example.com").start(callback) + + val request = mockAPI.takeRequest() + assertThat(request.getHeader("Authorization"), Matchers.equalTo("Bearer $ACCESS_TOKEN")) + assertThat(request.getHeader("DPoP"), Matchers.nullValue()) + } + + @Test + public fun `should not include DPoP proof header when DPoP is enabled but no key pair exists`() { + whenever(mockKeyStore.hasKeyPair()).thenReturn(false) + + val dpopClient = MyAccountAPIClient(auth0, ACCESS_TOKEN).useDPoP(mockContext) + val callback = MockMyAccountCallback>() + dpopClient.getFactors().start(callback) + + val request = mockAPI.takeRequest() + assertThat(request.getHeader("Authorization"), Matchers.equalTo("DPoP $ACCESS_TOKEN")) + assertThat(request.getHeader("DPoP"), Matchers.nullValue()) + } + + @Test + public fun `should use DPoP authorization header on PATCH requests when DPoP is enabled`() { + whenever(mockKeyStore.hasKeyPair()).thenReturn(true) + whenever(mockKeyStore.getKeyPair()).thenReturn(Pair(FakeECPrivateKey(), FakeECPublicKey())) + + val dpopClient = MyAccountAPIClient(auth0, ACCESS_TOKEN).useDPoP(mockContext) + val callback = MockMyAccountCallback() + dpopClient.updateAuthenticationMethodById("method|123", authenticationMethodName = "Test") + .start(callback) + + val request = mockAPI.takeRequest() + assertThat(request.getHeader("Authorization"), Matchers.equalTo("DPoP $ACCESS_TOKEN")) + assertThat(request.getHeader("DPoP"), Matchers.notNullValue()) + assertThat(request.method, Matchers.equalTo("PATCH")) + } + + @Test + public fun `should use DPoP authorization header on DELETE requests when DPoP is enabled`() { + whenever(mockKeyStore.hasKeyPair()).thenReturn(true) + whenever(mockKeyStore.getKeyPair()).thenReturn(Pair(FakeECPrivateKey(), FakeECPublicKey())) + + val dpopClient = MyAccountAPIClient(auth0, ACCESS_TOKEN).useDPoP(mockContext) + val callback = MockMyAccountCallback() + dpopClient.deleteAuthenticationMethod("method|123").start(callback) + + val request = mockAPI.takeRequest() + assertThat(request.getHeader("Authorization"), Matchers.equalTo("DPoP $ACCESS_TOKEN")) + assertThat(request.getHeader("DPoP"), Matchers.notNullValue()) + assertThat(request.method, Matchers.equalTo("DELETE")) + } + private fun bodyFromRequest(request: RecordedRequest): Map { val mapType = object : TypeToken?>() {}.type return gson.fromJson(request.body.readUtf8(), mapType) From 4be6b4ccf2eb7b27203fc3df52c2e7c9e33c9415 Mon Sep 17 00:00:00 2001 From: Prince Mathew <17837162+pmathew92@users.noreply.github.com> Date: Tue, 26 May 2026 12:28:29 +0530 Subject: [PATCH 34/60] Release 3.18.0 (#976) --- .version | 2 +- CHANGELOG.md | 9 +++++++++ README.md | 2 +- 3 files changed, 11 insertions(+), 2 deletions(-) diff --git a/.version b/.version index f85bf6e32..ae5615506 100644 --- a/.version +++ b/.version @@ -1 +1 @@ -3.17.0 \ No newline at end of file +3.18.0 \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index fa574f9f0..41a8fabb9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # Change Log +## [3.18.0](https://github.com/auth0/Auth0.Android/tree/3.18.0) (2026-05-26) +[Full Changelog](https://github.com/auth0/Auth0.Android/compare/3.17.0...3.18.0) + +**Added** +- feat : Add support to pass type parameter to the MyAccount list authentication method API [\#974](https://github.com/auth0/Auth0.Android/pull/974) ([pmathew92](https://github.com/pmathew92)) + +**Fixed** +- fix : Guard against all uncaught exception in the serial executor block [\#970](https://github.com/auth0/Auth0.Android/pull/970) ([pmathew92](https://github.com/pmathew92)) + ## [3.17.0](https://github.com/auth0/Auth0.Android/tree/3.17.0) (2026-05-21) [Full Changelog](https://github.com/auth0/Auth0.Android/compare/3.16.0...3.17.0) diff --git a/README.md b/README.md index fb107093b..df2196e18 100644 --- a/README.md +++ b/README.md @@ -60,7 +60,7 @@ To install Auth0.Android with [Gradle](https://gradle.org/), simply add the foll ```gradle dependencies { - implementation 'com.auth0.android:auth0:3.17.0' + implementation 'com.auth0.android:auth0:3.18.0' } ``` From 47f75aeebe039284937bae5641683fb89a81ecad Mon Sep 17 00:00:00 2001 From: Prince Mathew <17837162+pmathew92@users.noreply.github.com> Date: Mon, 1 Jun 2026 08:58:17 +0530 Subject: [PATCH 35/60] feat: CTE delegation and impersonation support (#975) --- .gitignore | 5 +- EXAMPLES.md | 91 +++++++++++ .../authentication/AuthenticationAPIClient.kt | 29 +++- .../authentication/request/ActorToken.kt | 20 +++ .../internal/UserProfileDeserializer.java | 40 ++++- .../com/auth0/android/result/ActorClaim.kt | 17 ++ .../com/auth0/android/result/UserProfile.kt | 10 +- .../AuthenticationAPIClientTest.kt | 123 ++++++++++++++ .../request/internal/UserProfileGsonTest.kt | 152 +++++++++++++++++- .../auth0/android/result/UserProfileTest.java | 6 +- 10 files changed, 476 insertions(+), 17 deletions(-) create mode 100644 auth0/src/main/java/com/auth0/android/authentication/request/ActorToken.kt create mode 100644 auth0/src/main/java/com/auth0/android/result/ActorClaim.kt diff --git a/.gitignore b/.gitignore index 4b9d13b91..8becb5216 100644 --- a/.gitignore +++ b/.gitignore @@ -96,4 +96,7 @@ gen-external-apklibs .TemporaryItems .Trashes -version.txt \ No newline at end of file +version.txt + +# Internal planning docs +plans/ \ No newline at end of file diff --git a/EXAMPLES.md b/EXAMPLES.md index d26eb1bdf..3c7a31142 100644 --- a/EXAMPLES.md +++ b/EXAMPLES.md @@ -30,6 +30,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) + - [Custom Token Exchange with Actor Token (Delegation/Impersonation)](#custom-token-exchange-with-actor-token-delegationimpersonation) - [Native to Web SSO login](#native-to-web-sso-login) - [Pushed Authorization Requests (PAR)](#pushed-authorization-requests-par) - [DPoP](#dpop-1) @@ -1637,6 +1638,96 @@ authentication +#### Custom Token Exchange with Actor Token (Delegation/Impersonation) + +For delegation or impersonation scenarios where one principal acts on behalf of another (e.g., an AI agent acting on behalf of a user), pass `ActorToken` with the actor token details: + +> **Note:** When `actor_token` is present in the request, Auth0 will not issue a refresh token regardless of whether `offline_access` is in the scope. The `Credentials.refreshToken` will be `null` in this flow. + +```kotlin +import com.auth0.android.authentication.request.ActorToken + +val actorToken = ActorToken( + token = "actor-token-value", + tokenType = "urn:my-org:actor-token-type" +) + +authentication + .customTokenExchange( + subjectTokenType = "http://my-org/custom-token", + subjectToken = "subject-token-value", + organization = "org_12345", + actorToken = actorToken + ) + .start(object : Callback { + override fun onSuccess(result: Credentials) { + // Access the actor claim from the ID token + val actor = result.user.actor + if (actor != null) { + println("Actor sub: ${actor.sub}") + println("Actor properties: ${actor.extraProperties}") + // Nested delegation chain (if present) + val nestedActor = actor.actor + } + } + + override fun onFailure(exception: AuthenticationException) { + // Handle error + } + }) +``` + +
+ Using coroutines + +```kotlin +try { + val actorToken = ActorToken( + token = "actor-token-value", + tokenType = "urn:my-org:actor-token-type" + ) + val credentials = authentication + .customTokenExchange( + subjectTokenType = "http://my-org/custom-token", + subjectToken = "subject-token-value", + actorToken = actorToken + ) + .await() + // Access the actor claim + val actor = credentials.user.actor +} catch (e: AuthenticationException) { + e.printStackTrace() +} +``` +
+ +
+ Using Java + +```java +ActorToken actorToken = new ActorToken( + "actor-token-value", + "urn:my-org:actor-token-type" +); + +authentication + .customTokenExchange("http://my-org/custom-token", "subject-token-value", null, actorToken) + .start(new Callback() { + @Override + public void onSuccess(@Nullable Credentials payload) { + ActorClaim actor = payload.getUser().getActor(); + if (actor != null) { + Log.d("CTE", "Actor: " + actor.getSub()); + } + } + @Override + public void onFailure(@NonNull AuthenticationException error) { + // Handle error + } + }); +``` +
+ ## Native to Web SSO login 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 1f986e917..94b956f3b 100755 --- a/auth0/src/main/java/com/auth0/android/authentication/AuthenticationAPIClient.kt +++ b/auth0/src/main/java/com/auth0/android/authentication/AuthenticationAPIClient.kt @@ -6,6 +6,7 @@ import com.auth0.android.Auth0 import com.auth0.android.Auth0Exception import com.auth0.android.NetworkErrorException import com.auth0.android.authentication.mfa.MfaApiClient +import com.auth0.android.authentication.request.ActorToken import com.auth0.android.dpop.DPoP import com.auth0.android.dpop.DPoPException import com.auth0.android.dpop.SenderConstraining @@ -815,17 +816,31 @@ public class AuthenticationAPIClient @VisibleForTesting(otherwise = VisibleForTe * }) * ``` * + * For delegation/impersonation scenarios, pass [ActorToken] with actor token details. + * When the server issues tokens with an `act` claim, it will be available via [Credentials.user] actor property. + * + * Note: When `actor_token` is present, Auth0 will not issue a refresh token regardless of + * whether `offline_access` is in the scope. The [Credentials.refreshToken] will be null. + * * @param subjectTokenType the subject token type that is associated with the existing Identity Provider. e.g. 'http://acme.com/legacy-token' * @param subjectToken the subject token, typically obtained through the Identity Provider's SDK * @param organization id of the organization the user belongs to + * @param actorToken optional actor token details for delegation/impersonation flows. * @return a request to configure and start that will yield [Credentials] */ + @JvmOverloads public fun customTokenExchange( subjectTokenType: String, subjectToken: String, - organization: String? = null + organization: String? = null, + actorToken: ActorToken? = null ): AuthenticationRequest { - return tokenExchange(subjectTokenType, subjectToken, organization) + return tokenExchange( + subjectTokenType, + subjectToken, + organization, + actorToken + ) } /** @@ -1108,7 +1123,8 @@ public class AuthenticationAPIClient @VisibleForTesting(otherwise = VisibleForTe private fun tokenExchange( subjectTokenType: String, subjectToken: String, - organization: String? = null + organization: String? = null, + actorToken: ActorToken? = null ): AuthenticationRequest { val parameters = ParameterBuilder.newAuthenticationBuilder().apply { setGrantType(ParameterBuilder.GRANT_TYPE_TOKEN_EXCHANGE) @@ -1117,6 +1133,10 @@ public class AuthenticationAPIClient @VisibleForTesting(otherwise = VisibleForTe organization?.let { set(ORGANIZATION_KEY, it) } + actorToken?.let { + set(ACTOR_TOKEN_KEY, it.token) + set(ACTOR_TOKEN_TYPE_KEY, it.tokenType) + } }.asDictionary() return loginWithToken(parameters) } @@ -1150,7 +1170,9 @@ public class AuthenticationAPIClient @VisibleForTesting(otherwise = VisibleForTe private const val AUTHENTICATOR_ID_KEY = "authenticator_id" private const val RECOVERY_CODE_KEY = "recovery_code" private const val SUBJECT_TOKEN_KEY = "subject_token" + private const val ACTOR_TOKEN_KEY = "actor_token" private const val SUBJECT_TOKEN_TYPE_KEY = "subject_token_type" + private const val ACTOR_TOKEN_TYPE_KEY = "actor_token_type" private const val ORGANIZATION_KEY = "organization" private const val USER_METADATA_KEY = "user_metadata" private const val AUTH_SESSION_KEY = "auth_session" @@ -1172,7 +1194,6 @@ public class AuthenticationAPIClient @VisibleForTesting(otherwise = VisibleForTe private const val HEADER_AUTHORIZATION = "Authorization" private const val WELL_KNOWN_PATH = ".well-known" private const val JWKS_FILE_PATH = "jwks.json" - private const val TAG = "AuthenticationAPIClient" private fun createErrorAdapter(): ErrorAdapter { val mapAdapter = forMap(GsonProvider.gson) return object : ErrorAdapter { diff --git a/auth0/src/main/java/com/auth0/android/authentication/request/ActorToken.kt b/auth0/src/main/java/com/auth0/android/authentication/request/ActorToken.kt new file mode 100644 index 000000000..68fc073b2 --- /dev/null +++ b/auth0/src/main/java/com/auth0/android/authentication/request/ActorToken.kt @@ -0,0 +1,20 @@ +package com.auth0.android.authentication.request + +/** + * Represents the acting party in a token exchange delegation/impersonation flow. + * + * An `ActorToken` bundles the token and its type URI together, ensuring both are always provided as required by + * [RFC 8693](https://tools.ietf.org/html/rfc8693). Auth0 requires both `actor_token` and `actor_token_type` to be + * present when performing delegation. + * + * @param token The token representing the acting party (the entity performing actions on behalf of the subject). + * @param tokenType A URI indicating the type of the actor token (e.g., `urn:ietf:params:oauth:token-type:id_token` + * or a custom URI like `http://corporate-idp/id-token`). + * + * @see [RFC 8693: OAuth 2.0 Token Exchange](https://tools.ietf.org/html/rfc8693#section-2.1) + * @see [Custom Token Exchange Documentation](https://auth0.com/docs/authenticate/custom-token-exchange) + */ +public data class ActorToken( + val token: String, + val tokenType: String +) diff --git a/auth0/src/main/java/com/auth0/android/request/internal/UserProfileDeserializer.java b/auth0/src/main/java/com/auth0/android/request/internal/UserProfileDeserializer.java index b40fbffd1..da6a380cc 100755 --- a/auth0/src/main/java/com/auth0/android/request/internal/UserProfileDeserializer.java +++ b/auth0/src/main/java/com/auth0/android/request/internal/UserProfileDeserializer.java @@ -1,5 +1,8 @@ package com.auth0.android.request.internal; +import android.util.Log; + +import com.auth0.android.result.ActorClaim; import com.auth0.android.result.UserIdentity; import com.auth0.android.result.UserProfile; import com.google.gson.Gson; @@ -11,12 +14,15 @@ import com.google.gson.reflect.TypeToken; import java.lang.reflect.Type; +import java.util.Collections; import java.util.Date; import java.util.List; import java.util.Map; class UserProfileDeserializer implements JsonDeserializer { + private static final String TAG = UserProfileDeserializer.class.getSimpleName(); + private final Gson iso8601DateGson; public UserProfileDeserializer() { @@ -41,13 +47,41 @@ public UserProfile deserialize(JsonElement json, Type typeOfT, JsonDeserializati final Boolean emailVerified = object.has("email_verified") ? context.deserialize(object.remove("email_verified"), Boolean.class) : false; final Date createdAt = iso8601DateGson.fromJson(object.remove("created_at"), Date.class); - final Type identitiesType = new TypeToken>() {}.getType(); + final Type identitiesType = new TypeToken>() { + }.getType(); final List identities = context.deserialize(object.remove("identities"), identitiesType); - final Type metadataType = new TypeToken>() {}.getType(); + final ActorClaim actor = deserializeActorClaim(object.remove("act"), context); + + final Type metadataType = new TypeToken>() { + }.getType(); Map userMetadata = context.deserialize(object.remove("user_metadata"), metadataType); Map appMetadata = context.deserialize(object.remove("app_metadata"), metadataType); Map extraInfo = context.deserialize(object, metadataType); - return new UserProfile(id, name, nickname, picture, email, emailVerified, familyName, createdAt, identities, extraInfo, userMetadata, appMetadata, givenName); + return new UserProfile(id, name, nickname, picture, email, emailVerified, familyName, createdAt, identities, extraInfo, userMetadata, appMetadata, givenName, actor); + } + + private ActorClaim deserializeActorClaim(JsonElement actElement, JsonDeserializationContext context) { + if (actElement == null || actElement.isJsonNull() || !actElement.isJsonObject()) { + return null; + } + + JsonObject actObject = actElement.getAsJsonObject(); + String sub = context.deserialize(actObject.remove("sub"), String.class); + if (sub == null) { + Log.w(TAG, "act claim present but missing required 'sub' field, ignoring actor"); + return null; + } + + ActorClaim nestedActor = deserializeActorClaim(actObject.remove("act"), context); + + final Type mapType = new TypeToken>() { + }.getType(); + Map extraProperties = context.deserialize(actObject, mapType); + if (extraProperties == null) { + extraProperties = Collections.emptyMap(); + } + + return new ActorClaim(sub, nestedActor, extraProperties); } } diff --git a/auth0/src/main/java/com/auth0/android/result/ActorClaim.kt b/auth0/src/main/java/com/auth0/android/result/ActorClaim.kt new file mode 100644 index 000000000..4f6998890 --- /dev/null +++ b/auth0/src/main/java/com/auth0/android/result/ActorClaim.kt @@ -0,0 +1,17 @@ +package com.auth0.android.result + +import java.io.Serializable + +/** + * Represents the `act` (actor) claim in an ID token, used in delegation and impersonation scenarios. + * See RFC 8693 Section 4.4 for the specification of the `act` claim. + * + * @param sub The unique identifier of the actor (required). + * @param actor A nested actor claim representing a delegation chain. + * @param extraProperties Additional custom properties set via the `setActor` Action command. + */ +public data class ActorClaim( + val sub: String, + val actor: ActorClaim? = null, + val extraProperties: Map = emptyMap() +) : Serializable diff --git a/auth0/src/main/java/com/auth0/android/result/UserProfile.kt b/auth0/src/main/java/com/auth0/android/result/UserProfile.kt index 2f4903139..be63c564e 100755 --- a/auth0/src/main/java/com/auth0/android/result/UserProfile.kt +++ b/auth0/src/main/java/com/auth0/android/result/UserProfile.kt @@ -7,7 +7,7 @@ import java.util.* * Class that holds the information of a user's profile in Auth0. * Used both in [com.auth0.android.management.UsersAPIClient] and [com.auth0.android.authentication.AuthenticationAPIClient]. */ -public class UserProfile( +public class UserProfile @JvmOverloads constructor( private val id: String?, public val name: String?, public val nickname: String?, @@ -25,7 +25,13 @@ public class UserProfile( private val extraInfo: Map?, private val userMetadata: Map?, private val appMetadata: Map?, - public val givenName: String? + public val givenName: String?, + /** + * The actor claim from the ID token, representing the acting party in delegation + * or impersonation scenarios (e.g., an AI agent acting on behalf of a user). + * Only present when the token was issued via Custom Token Exchange with an actor. + */ + public val actor: ActorClaim? = null ) : Serializable { /** diff --git a/auth0/src/test/java/com/auth0/android/authentication/AuthenticationAPIClientTest.kt b/auth0/src/test/java/com/auth0/android/authentication/AuthenticationAPIClientTest.kt index acc23a85a..72ca29fe1 100755 --- a/auth0/src/test/java/com/auth0/android/authentication/AuthenticationAPIClientTest.kt +++ b/auth0/src/test/java/com/auth0/android/authentication/AuthenticationAPIClientTest.kt @@ -4,6 +4,7 @@ import android.content.Context import android.content.res.Resources import com.auth0.android.Auth0 import com.auth0.android.authentication.ParameterBuilder.Companion.newBuilder +import com.auth0.android.authentication.request.ActorToken import com.auth0.android.dpop.DPoPException import com.auth0.android.dpop.DPoPKeyStore import com.auth0.android.dpop.DPoPUtil @@ -2423,6 +2424,128 @@ public class AuthenticationAPIClientTest { assertThat(credentials, Matchers.`is`(Matchers.notNullValue())) } + @Test + public fun shouldCustomTokenExchangeWithOptions() { + mockAPI.willReturnSuccessfulLogin() + val callback = MockAuthenticationCallback() + val actorToken = ActorToken( + token = "actor-token-value", + tokenType = "urn:custom:actor-token-type" + ) + client.customTokenExchange( + "subject-token-type", + "subject-token", + "org_12345", + actorToken + ).start(callback) + ShadowLooper.idleMainLooper() + val request = mockAPI.takeRequest() + assertThat(request.path, Matchers.equalTo("/oauth/token")) + val body = bodyFromRequest(request) + assertThat(body, Matchers.hasEntry("client_id", CLIENT_ID)) + assertThat( + body, + Matchers.hasEntry("grant_type", ParameterBuilder.GRANT_TYPE_TOKEN_EXCHANGE) + ) + assertThat(body, Matchers.hasEntry("subject_token", "subject-token")) + assertThat(body, Matchers.hasEntry("subject_token_type", "subject-token-type")) + assertThat(body, Matchers.hasEntry("organization", "org_12345")) + assertThat(body, Matchers.hasEntry("actor_token", "actor-token-value")) + assertThat(body, Matchers.hasEntry("actor_token_type", "urn:custom:actor-token-type")) + assertThat(body, Matchers.hasEntry("scope", "openid profile email")) + assertThat( + callback, AuthenticationCallbackMatcher.hasPayloadOfType( + Credentials::class.java + ) + ) + } + + @Test + public fun shouldCustomTokenExchangeWithOptionsSyncWithoutOrganization() { + mockAPI.willReturnSuccessfulLogin() + val actorToken = ActorToken( + token = "actor-token-value", + tokenType = "urn:custom:actor-token-type" + ) + val credentials = client + .customTokenExchange( + "subject-token-type", + "subject-token", + actorToken = actorToken + ) + .execute() + val request = mockAPI.takeRequest() + assertThat(request.path, Matchers.equalTo("/oauth/token")) + val body = bodyFromRequest(request) + assertThat(body, Matchers.hasEntry("client_id", CLIENT_ID)) + assertThat( + body, + Matchers.hasEntry("grant_type", ParameterBuilder.GRANT_TYPE_TOKEN_EXCHANGE) + ) + assertThat(body, Matchers.hasEntry("subject_token", "subject-token")) + assertThat(body, Matchers.hasEntry("subject_token_type", "subject-token-type")) + assertThat(body, Matchers.not(Matchers.hasKey("organization"))) + assertThat(body, Matchers.hasEntry("actor_token", "actor-token-value")) + assertThat(body, Matchers.hasEntry("actor_token_type", "urn:custom:actor-token-type")) + assertThat(body, Matchers.hasEntry("scope", "openid profile email")) + assertThat(credentials, Matchers.`is`(Matchers.notNullValue())) + } + + @Test + @ExperimentalCoroutinesApi + public fun shouldAwaitCustomTokenExchangeWithOptions(): Unit = runTest { + mockAPI.willReturnSuccessfulLogin() + val actorToken = ActorToken( + token = "actor-token-value", + tokenType = "urn:custom:actor-token-type" + ) + val credentials = client + .customTokenExchange( + "subject-token-type", + "subject-token", + "org_abc", + actorToken + ) + .await() + val request = mockAPI.takeRequest() + assertThat(request.path, Matchers.equalTo("/oauth/token")) + val body = bodyFromRequest(request) + assertThat(body, Matchers.hasEntry("client_id", CLIENT_ID)) + assertThat( + body, + Matchers.hasEntry("grant_type", ParameterBuilder.GRANT_TYPE_TOKEN_EXCHANGE) + ) + assertThat(body, Matchers.hasEntry("subject_token", "subject-token")) + assertThat(body, Matchers.hasEntry("subject_token_type", "subject-token-type")) + assertThat(body, Matchers.hasEntry("organization", "org_abc")) + assertThat(body, Matchers.hasEntry("actor_token", "actor-token-value")) + assertThat(body, Matchers.hasEntry("actor_token_type", "urn:custom:actor-token-type")) + assertThat(body, Matchers.hasEntry("scope", "openid profile email")) + assertThat(credentials, Matchers.`is`(Matchers.notNullValue())) + } + + @Test + public fun shouldCustomTokenExchangeWithoutOptions() { + mockAPI.willReturnSuccessfulLogin() + val callback = MockAuthenticationCallback() + client.customTokenExchange("subject-token-type", "subject-token", "org_12345") + .start(callback) + ShadowLooper.idleMainLooper() + val request = mockAPI.takeRequest() + assertThat(request.path, Matchers.equalTo("/oauth/token")) + val body = bodyFromRequest(request) + assertThat(body, Matchers.hasEntry("subject_token", "subject-token")) + assertThat(body, Matchers.hasEntry("subject_token_type", "subject-token-type")) + assertThat(body, Matchers.hasEntry("organization", "org_12345")) + assertThat(body, Matchers.not(Matchers.hasKey("actor_token"))) + assertThat(body, Matchers.not(Matchers.hasKey("actor_token_type"))) + assertThat( + callback, AuthenticationCallbackMatcher.hasPayloadOfType( + Credentials::class.java + ) + ) + } + @Test public fun shouldSsoExchange() { mockAPI.willReturnSuccessfulLogin() diff --git a/auth0/src/test/java/com/auth0/android/request/internal/UserProfileGsonTest.kt b/auth0/src/test/java/com/auth0/android/request/internal/UserProfileGsonTest.kt index 884ddf25f..a46e3f4c1 100755 --- a/auth0/src/test/java/com/auth0/android/request/internal/UserProfileGsonTest.kt +++ b/auth0/src/test/java/com/auth0/android/request/internal/UserProfileGsonTest.kt @@ -6,15 +6,29 @@ import com.auth0.android.util.UserIdentityMatcher import com.auth0.android.util.UserProfileMatcher import com.google.gson.JsonParseException import org.hamcrest.MatcherAssert.assertThat -import org.hamcrest.Matchers.* +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.hamcrest.Matchers.anyOf +import org.hamcrest.Matchers.emptyCollectionOf +import org.hamcrest.Matchers.equalTo +import org.hamcrest.Matchers.hasEntry +import org.hamcrest.Matchers.hasItem +import org.hamcrest.Matchers.hasKey +import org.hamcrest.Matchers.hasSize +import org.hamcrest.Matchers.`is` +import org.hamcrest.Matchers.not +import org.hamcrest.Matchers.notNullValue +import org.hamcrest.Matchers.nullValue import org.hamcrest.collection.IsMapWithSize import org.junit.Assert import org.junit.Before import org.junit.Test import java.io.StringReader -import java.text.SimpleDateFormat -import java.util.* +import java.util.Calendar +import java.util.Date +import java.util.TimeZone +@RunWith(RobolectricTestRunner::class) public class UserProfileGsonTest : GsonBaseTest() { @Before @@ -345,7 +359,137 @@ public class UserProfileGsonTest : GsonBaseTest() { ) } - private fun getUTCDate(year: Int, month: Int, day: Int, hr: Int, min: Int, sec: Int, ms: Int): Date { + @Test + @Throws(Exception::class) + public fun shouldReturnProfileWithActorClaim() { + val userProfile = pojoFrom( + StringReader( + """{ + "sub": "auth0|123", + "name": "Test User", + "act": { + "sub": "agent-id-456", + "type": "ai_agent", + "name": "My Agent" + } +}""" + ), UserProfile::class.java + ) + assertThat(userProfile, `is`(notNullValue())) + assertThat(userProfile.actor, `is`(notNullValue())) + assertThat(userProfile.actor!!.sub, `is`("agent-id-456")) + assertThat(userProfile.actor!!.extraProperties, hasEntry("type", "ai_agent" as Any)) + assertThat(userProfile.actor!!.extraProperties, hasEntry("name", "My Agent" as Any)) + assertThat(userProfile.actor!!.actor, `is`(nullValue())) + } + + @Test + @Throws(Exception::class) + public fun shouldReturnProfileWithNestedActorClaim() { + val userProfile = pojoFrom( + StringReader( + """{ + "sub": "auth0|123", + "name": "Test User", + "act": { + "sub": "agent-id-456", + "act": { + "sub": "service-id-789", + "role": "intermediary" + } + } +}""" + ), UserProfile::class.java + ) + assertThat(userProfile, `is`(notNullValue())) + assertThat(userProfile.actor, `is`(notNullValue())) + assertThat(userProfile.actor!!.sub, `is`("agent-id-456")) + assertThat(userProfile.actor!!.actor, `is`(notNullValue())) + assertThat(userProfile.actor!!.actor!!.sub, `is`("service-id-789")) + assertThat( + userProfile.actor!!.actor!!.extraProperties, + hasEntry("role", "intermediary" as Any) + ) + assertThat(userProfile.actor!!.actor!!.actor, `is`(nullValue())) + } + + @Test + @Throws(Exception::class) + public fun shouldReturnProfileWithNullActorWhenNotPresent() { + val userProfile = pojoFrom( + StringReader( + """{ + "sub": "auth0|123", + "name": "Test User" +}""" + ), UserProfile::class.java + ) + assertThat(userProfile, `is`(notNullValue())) + assertThat(userProfile.actor, `is`(nullValue())) + } + + @Test + @Throws(Exception::class) + public fun shouldReturnProfileWithNullActorWhenActIsNull() { + val userProfile = pojoFrom( + StringReader( + """{ + "sub": "auth0|123", + "name": "Test User", + "act": null +}""" + ), UserProfile::class.java + ) + assertThat(userProfile, `is`(notNullValue())) + assertThat(userProfile.actor, `is`(nullValue())) + } + + @Test + @Throws(Exception::class) + public fun shouldReturnProfileWithNullActorWhenActHasNoSub() { + val userProfile = pojoFrom( + StringReader( + """{ + "sub": "auth0|123", + "name": "Test User", + "act": { + "type": "ai_agent" + } +}""" + ), UserProfile::class.java + ) + assertThat(userProfile, `is`(notNullValue())) + assertThat(userProfile.actor, `is`(nullValue())) + } + + @Test + @Throws(Exception::class) + public fun shouldNotIncludeActInExtraInfo() { + val userProfile = pojoFrom( + StringReader( + """{ + "sub": "auth0|123", + "name": "Test User", + "act": { + "sub": "agent-id-456" + } +}""" + ), UserProfile::class.java + ) + assertThat(userProfile, `is`(notNullValue())) + assertThat(userProfile.actor, `is`(notNullValue())) + assertThat(userProfile.getExtraInfo(), not(hasKey("act"))) + } + + private fun getUTCDate( + year: Int, + month: Int, + day: Int, + hr: Int, + min: Int, + sec: Int, + ms: Int + ): Date { val cal = Calendar.getInstance(TimeZone.getTimeZone("UTC")) cal[Calendar.YEAR] = year cal[Calendar.MONTH] = month diff --git a/auth0/src/test/java/com/auth0/android/result/UserProfileTest.java b/auth0/src/test/java/com/auth0/android/result/UserProfileTest.java index f764335cf..d0fbaa9ec 100644 --- a/auth0/src/test/java/com/auth0/android/result/UserProfileTest.java +++ b/auth0/src/test/java/com/auth0/android/result/UserProfileTest.java @@ -29,7 +29,7 @@ public void setUp() { extraInfo = Collections.emptyMap(); userMetadata = Collections.emptyMap(); appMetadata = Collections.emptyMap(); - userProfile = new UserProfile("id", "name", "nickname", "pictureUrl", "email", true, "familyName", createdAt, identities, extraInfo, userMetadata, appMetadata, "givenName"); + userProfile = new UserProfile("id", "name", "nickname", "pictureUrl", "email", true, "familyName", createdAt, identities, extraInfo, userMetadata, appMetadata, "givenName", null); } @Test @@ -40,13 +40,13 @@ public void getId() { @Test public void shouldReturnSubIfMissingId() { Map extraInfo = Collections.singletonMap("sub", "fromSub"); - UserProfile userProfile = new UserProfile(null, null, null, null, null, false, null, null, null, extraInfo, null, null, null); + UserProfile userProfile = new UserProfile(null, null, null, null, null, false, null, null, null, extraInfo, null, null, null, null); assertThat(userProfile.getId(), is("fromSub")); } @Test public void shouldGetNullIdIfMissing() { - UserProfile userProfile = new UserProfile(null, null, null, null, null, false, null, null, null, null, null, null, null); + UserProfile userProfile = new UserProfile(null, null, null, null, null, false, null, null, null, null, null, null, null, null); assertThat(userProfile.getId(), is(nullValue())); } From 0a8ed2d1408f080f3d9843da00d36c368c37ac31 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 8 Jun 2026 15:00:12 +0530 Subject: [PATCH 36/60] chore(deps): bump codecov/codecov-action from 6.0.1 to 7.0.0 (#978) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index cf0560d61..ee03da932 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -29,4 +29,4 @@ jobs: - run: ./gradlew clean test jacocoTestReport lint --continue --console=plain --max-workers=1 --no-daemon - - uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # pin@6.0.1 + - uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # pin@7.0.0 From 5d3ddd4c993468fd881d30ae9a9480ea6ba988c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Carlos=20Ch=C3=A1vez?= Date: Wed, 10 Jun 2026 08:51:07 +0200 Subject: [PATCH 37/60] chore(security): uses pinned versions of actions (#979) --- .github/workflows/codeql.yml | 10 +++++----- .github/workflows/java-release.yml | 2 +- .github/workflows/release.yml | 4 ++-- .github/workflows/test.yml | 2 +- 4 files changed, 9 insertions(+), 9 deletions(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index d9d498320..ddce02047 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -36,24 +36,24 @@ jobs: run: exit 0 # Skip unnecessary test runs for dependabot and merge queues. Artifically flag as successful, as this is a required check for branch protection. - name: Set up Java - uses: actions/setup-java@v5 + uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0 with: distribution: 'temurin' java-version: '11' - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - name: Initialize CodeQL - uses: github/codeql-action/init@v4 + uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2 with: languages: ${{ matrix.language }} queries: +security-and-quality - name: Autobuild - uses: github/codeql-action/autobuild@v4 + uses: github/codeql-action/autobuild@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2 - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v4 + uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2 with: category: "/language:${{ matrix.language }}" diff --git a/.github/workflows/java-release.yml b/.github/workflows/java-release.yml index 32e097978..5221cf5f0 100644 --- a/.github/workflows/java-release.yml +++ b/.github/workflows/java-release.yml @@ -30,7 +30,7 @@ jobs: steps: # Checkout the code - - uses: actions/checkout@v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: fetch-depth: 0 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 883300f1b..423125458 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -19,7 +19,7 @@ jobs: if: github.event_name == 'workflow_dispatch' || (github.event_name == 'pull_request' && github.event.pull_request.merged && startsWith(github.event.pull_request.head.ref, 'release/')) runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: fetch-depth: 0 @@ -33,7 +33,7 @@ jobs: echo "version=$version" >> $GITHUB_OUTPUT - name: Run RL Scanner - uses: auth0/devsecops-tooling/.github/actions/rl-scan@main + uses: auth0/devsecops-tooling/.github/actions/rl-scan@e29f26478db18ff0bcbe4bc447a8fbd54fbeec9e # main on 2026-06-09, TODO: use a release instead with: artifact-name: "auth0-android" artifact-path: "${{ github.workspace }}/auth0/build/outputs/aar/auth0-release.aar" diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index ee03da932..5c642ebac 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -23,7 +23,7 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - uses: ./.github/actions/setup From 5b86b710362b8a55d7e0d50d51011c359c7fdb6b Mon Sep 17 00:00:00 2001 From: Prince Mathew <17837162+pmathew92@users.noreply.github.com> Date: Fri, 12 Jun 2026 11:47:11 +0530 Subject: [PATCH 38/60] doc: Removed EA tag from MyAccount APIs (#980) --- EXAMPLES.md | 10 +---- .../android/myaccount/MyAccountAPIClient.kt | 38 ------------------- 2 files changed, 2 insertions(+), 46 deletions(-) diff --git a/EXAMPLES.md b/EXAMPLES.md index 3c7a31142..1520f3cff 100644 --- a/EXAMPLES.md +++ b/EXAMPLES.md @@ -1929,12 +1929,9 @@ val manager = CredentialsManager(apiClient, storage) ## My Account API -> [!NOTE] -> The My Account API 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. - Use the Auth0 My Account API to manage the current user's account. -To call the My Account API, you need an access token issued specifically for this API, including any required scopes for the operations you want to perform. See [API credentials [EA]](#api-credentials-ea) to learn how to obtain one. +To call the My Account API, you need an access token issued specifically for this API, including any required scopes for the operations you want to perform. See [API credentials](#api-credentials) to learn how to obtain one. ```kotlin val client = MyAccountAPIClient(auth0, accessToken) @@ -2809,10 +2806,7 @@ val isValid = secureCredentialsManager.isBiometricSessionValid() ### Other Credentials -#### API credentials [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. +#### API credentials When the user logs in, you can request an access token for a specific API by passing its API identifier as the [audience](#specify-audience) value. The access token in the resulting credentials can then be used to make authenticated requests to that API. 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 14ad4b4c6..994616162 100644 --- a/auth0/src/main/java/com/auth0/android/myaccount/MyAccountAPIClient.kt +++ b/auth0/src/main/java/com/auth0/android/myaccount/MyAccountAPIClient.kt @@ -108,12 +108,6 @@ public class MyAccountAPIClient @VisibleForTesting(otherwise = VisibleForTesting * You can specify an optional user identity identifier and an optional database connection name. * If a connection name is not specified, your tenant's default directory will be used. * - * ## 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. - * * ## Scopes Required * * `create:me:authentication_methods` @@ -205,12 +199,6 @@ public class MyAccountAPIClient @VisibleForTesting(otherwise = VisibleForTesting /** * Enrolls a new passkey credential. This is the last part of the enrollment flow. * - * ## 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. - * * ## Scopes Required * * `create:me:authentication_methods` @@ -277,12 +265,6 @@ public class MyAccountAPIClient @VisibleForTesting(otherwise = VisibleForTesting /** * Retrieves a detailed list of authentication methods belonging to the user. * - * ## 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. - * * ## Scopes Required * * `read:me:authentication_methods` @@ -346,13 +328,6 @@ public class MyAccountAPIClient @VisibleForTesting(otherwise = VisibleForTesting /** * Retrieves a single authentication method belonging to the user. * - * ## 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. - * - * * ## Usage * * ```kotlin @@ -387,13 +362,6 @@ public class MyAccountAPIClient @VisibleForTesting(otherwise = VisibleForTesting /** * Updates a single authentication method belonging to the user. * - * ## 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. - * - * * ## Usage * * ```kotlin @@ -448,12 +416,6 @@ public class MyAccountAPIClient @VisibleForTesting(otherwise = VisibleForTesting /** * Deletes an existing authentication method belonging to the user. * - * ## 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. - * * ## Scopes Required * `delete:me:authentication_methods` * From 7eb9dabdca3562701ebcd2452082c7d32a0bef4d Mon Sep 17 00:00:00 2001 From: Prince Mathew <17837162+pmathew92@users.noreply.github.com> Date: Mon, 15 Jun 2026 10:22:58 +0530 Subject: [PATCH 39/60] fix : Handling DPoP enabled WebAuth flow after process death (#977) Co-authored-by: Subhankar Maiti Co-authored-by: Subhankar Maiti <35273200+subhankarmaiti@users.noreply.github.com> --- auth0/build.gradle | 1 + .../provider/AuthenticationActivity.kt | 2 +- .../provider/LifecycleAwareCallback.kt | 53 +++ .../auth0/android/provider/OAuthManager.kt | 15 +- .../android/provider/OAuthManagerState.kt | 9 +- .../auth0/android/provider/WebAuthProvider.kt | 193 ++++++++- .../android/provider/OAuthManagerStateTest.kt | 142 +++++++ .../android/provider/WebAuthProviderTest.kt | 388 +++++++++++++++++- .../com/auth0/sample/DatabaseLoginFragment.kt | 28 +- 9 files changed, 805 insertions(+), 26 deletions(-) create mode 100644 auth0/src/main/java/com/auth0/android/provider/LifecycleAwareCallback.kt diff --git a/auth0/build.gradle b/auth0/build.gradle index edb14a9a3..696042145 100644 --- a/auth0/build.gradle +++ b/auth0/build.gradle @@ -88,6 +88,7 @@ dependencies { implementation 'androidx.core:core-ktx:1.6.0' implementation 'androidx.appcompat:appcompat:1.6.0' implementation 'androidx.browser:browser:1.8.0' + implementation 'androidx.lifecycle:lifecycle-common:2.5.1' implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutinesVersion" implementation "com.squareup.okhttp3:okhttp:$okhttpVersion" diff --git a/auth0/src/main/java/com/auth0/android/provider/AuthenticationActivity.kt b/auth0/src/main/java/com/auth0/android/provider/AuthenticationActivity.kt index 5f3c3f4e6..b0f413cc6 100644 --- a/auth0/src/main/java/com/auth0/android/provider/AuthenticationActivity.kt +++ b/auth0/src/main/java/com/auth0/android/provider/AuthenticationActivity.kt @@ -40,7 +40,7 @@ public open class AuthenticationActivity : Activity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) if (savedInstanceState != null) { - WebAuthProvider.onRestoreInstanceState(savedInstanceState) + WebAuthProvider.onRestoreInstanceState(savedInstanceState, this) intentLaunched = savedInstanceState.getBoolean(EXTRA_INTENT_LAUNCHED, false) } } diff --git a/auth0/src/main/java/com/auth0/android/provider/LifecycleAwareCallback.kt b/auth0/src/main/java/com/auth0/android/provider/LifecycleAwareCallback.kt new file mode 100644 index 000000000..e1081e491 --- /dev/null +++ b/auth0/src/main/java/com/auth0/android/provider/LifecycleAwareCallback.kt @@ -0,0 +1,53 @@ +package com.auth0.android.provider + +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.LifecycleOwner +import com.auth0.android.authentication.AuthenticationException +import com.auth0.android.callback.Callback + +/** + * Wraps a user-provided callback and observes the Activity/Fragment lifecycle. + * When the host is destroyed (e.g. config change), [delegateCallback] is set to null so + * the destroyed Activity is no longer referenced by the SDK. + * + * If a result arrives after [delegateCallback] has been cleared, the [onDetached] lambda + * is invoked to cache the result for later recovery via resumePending*Result(). + * + * @param S the success type (Credentials for login, Void? for logout) + * @param delegateCallback the user's original callback + * @param lifecycleOwner the Activity or Fragment whose lifecycle to observe + * @param onDetached called when a result arrives but the callback is already detached + */ +internal class LifecycleAwareCallback( + @Volatile private var delegateCallback: Callback?, + lifecycleOwner: LifecycleOwner, + private val onDetached: (success: S?, error: AuthenticationException?) -> Unit, +) : Callback, DefaultLifecycleObserver { + + init { + lifecycleOwner.lifecycle.addObserver(this) + } + + override fun onSuccess(result: S) { + val cb = delegateCallback + if (cb != null) { + cb.onSuccess(result) + } else { + onDetached(result, null) + } + } + + override fun onFailure(error: AuthenticationException) { + val cb = delegateCallback + if (cb != null) { + cb.onFailure(error) + } else { + onDetached(null, error) + } + } + + override fun onDestroy(owner: LifecycleOwner) { + delegateCallback = null + owner.lifecycle.removeObserver(this) + } +} diff --git a/auth0/src/main/java/com/auth0/android/provider/OAuthManager.kt b/auth0/src/main/java/com/auth0/android/provider/OAuthManager.kt index acc2b2864..242a17335 100644 --- a/auth0/src/main/java/com/auth0/android/provider/OAuthManager.kt +++ b/auth0/src/main/java/com/auth0/android/provider/OAuthManager.kt @@ -29,6 +29,7 @@ internal class OAuthManager( @get:VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) internal val dPoP: DPoP? = null ) : ResumableManager() { + private val parameters: MutableMap private val headers: MutableMap private val ctOptions: CustomTabsOptions @@ -211,7 +212,8 @@ internal class OAuthManager( auth0 = account, idTokenVerificationIssuer = idTokenVerificationIssuer, idTokenVerificationLeeway = idTokenVerificationLeeway, - customAuthorizeUrl = this.customAuthorizeUrl + customAuthorizeUrl = this.customAuthorizeUrl, + dPoPEnabled = dPoP != null ) } @@ -387,14 +389,21 @@ internal class OAuthManager( internal fun OAuthManager.Companion.fromState( state: OAuthManagerState, - callback: Callback + callback: Callback, + context: Context ): OAuthManager { + // Enable DPoP on the restored PKCE's AuthenticationAPIClient so that + // the token exchange request includes the DPoP proof after process restore. + if (state.dPoPEnabled && state.pkce != null) { + state.pkce.apiClient.useDPoP(context) + } return OAuthManager( account = state.auth0, ctOptions = state.ctOptions, parameters = state.parameters, callback = callback, - customAuthorizeUrl = state.customAuthorizeUrl + customAuthorizeUrl = state.customAuthorizeUrl, + dPoP = if (state.dPoPEnabled) DPoP(context) else null ).apply { setHeaders( state.headers diff --git a/auth0/src/main/java/com/auth0/android/provider/OAuthManagerState.kt b/auth0/src/main/java/com/auth0/android/provider/OAuthManagerState.kt index ab677af6c..9f10bb8e5 100644 --- a/auth0/src/main/java/com/auth0/android/provider/OAuthManagerState.kt +++ b/auth0/src/main/java/com/auth0/android/provider/OAuthManagerState.kt @@ -6,7 +6,6 @@ import android.util.Base64 import androidx.core.os.ParcelCompat import com.auth0.android.Auth0 import com.auth0.android.authentication.AuthenticationAPIClient -import com.auth0.android.dpop.DPoP import com.auth0.android.request.internal.GsonProvider import com.google.gson.Gson @@ -20,7 +19,7 @@ internal data class OAuthManagerState( val idTokenVerificationLeeway: Int?, val idTokenVerificationIssuer: String?, val customAuthorizeUrl: String? = null, - val dPoP: DPoP? = null + val dPoPEnabled: Boolean = false ) { private class OAuthManagerJson( @@ -37,7 +36,7 @@ internal data class OAuthManagerState( val idTokenVerificationLeeway: Int?, val idTokenVerificationIssuer: String?, val customAuthorizeUrl: String? = null, - val dPoP: DPoP? = null + val dPoPEnabled: Boolean = false ) fun serializeToJson( @@ -62,7 +61,7 @@ internal data class OAuthManagerState( idTokenVerificationIssuer = idTokenVerificationIssuer, idTokenVerificationLeeway = idTokenVerificationLeeway, customAuthorizeUrl = this.customAuthorizeUrl, - dPoP = this.dPoP + dPoPEnabled = this.dPoPEnabled ) return gson.toJson(json) } finally { @@ -112,7 +111,7 @@ internal data class OAuthManagerState( idTokenVerificationIssuer = oauthManagerJson.idTokenVerificationIssuer, idTokenVerificationLeeway = oauthManagerJson.idTokenVerificationLeeway, customAuthorizeUrl = oauthManagerJson.customAuthorizeUrl, - dPoP = oauthManagerJson.dPoP + dPoPEnabled = oauthManagerJson.dPoPEnabled ) } finally { parcel.recycle() diff --git a/auth0/src/main/java/com/auth0/android/provider/WebAuthProvider.kt b/auth0/src/main/java/com/auth0/android/provider/WebAuthProvider.kt index 6d647a432..82967e3f7 100644 --- a/auth0/src/main/java/com/auth0/android/provider/WebAuthProvider.kt +++ b/auth0/src/main/java/com/auth0/android/provider/WebAuthProvider.kt @@ -6,6 +6,8 @@ import android.net.Uri import android.os.Bundle import android.util.Log import androidx.annotation.VisibleForTesting +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.LifecycleOwner import com.auth0.android.Auth0 import com.auth0.android.authentication.AuthenticationException import com.auth0.android.callback.Callback @@ -18,6 +20,7 @@ import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.withContext import java.util.Locale import java.util.concurrent.CopyOnWriteArraySet +import java.util.concurrent.atomic.AtomicReference import kotlin.coroutines.CoroutineContext import kotlin.coroutines.resume import kotlin.coroutines.resumeWithException @@ -43,11 +46,93 @@ public object WebAuthProvider : SenderConstraining { internal var managerInstance: ResumableManager? = null private set + /** + * Represents a pending authentication or logout result that arrived while + * the original callback was no longer reachable (e.g. Activity destroyed + * during a configuration change). + */ + private sealed class PendingResult { + data class Success(val result: S) : PendingResult() + data class Failure(val error: AuthenticationException) : PendingResult() + } + + private val pendingLoginResult = AtomicReference?>(null) + + private val pendingLogoutResult = AtomicReference?>(null) + + /** + * Registers login and logout callbacks for the duration of the given + * [lifecycleOwner]'s lifetime. Call this once in `onCreate()` — it covers both recovery + * scenarios automatically: + * + * - **Process death**: callbacks are registered immediately so that if the process + * was killed while the browser was open, results are delivered when the Activity is + * restored. Callbacks are automatically unregistered when [lifecycleOwner] is destroyed, + * so there is no need to call [removeCallback] manually. + * - **Configuration change** (rotation, locale, dark mode): any login or logout result + * that arrived while the Activity was being recreated is delivered on the next `onResume`. + * + * Both callbacks are required to ensure results are not lost during configuration changes + * (e.g. user logs out to switch accounts and rotates during the logout flow). + * + * ```kotlin + * override fun onCreate(savedInstanceState: Bundle?) { + * super.onCreate(savedInstanceState) + * WebAuthProvider.registerCallbacks(this, loginCallback = callback, logoutCallback = voidCallback) + * } + * ``` + * + * @param lifecycleOwner the Activity or Fragment whose lifecycle to observe + * @param loginCallback receives login results (both direct delivery and recovered results) + * @param logoutCallback receives logout results recovered after a configuration change + */ + @JvmStatic + public fun registerCallbacks( + lifecycleOwner: LifecycleOwner, + loginCallback: Callback, + logoutCallback: Callback, + ) { + // Process-death recovery: register immediately so result is routed here on restore + callbacks += loginCallback + lifecycleOwner.lifecycle.addObserver(object : DefaultLifecycleObserver { + override fun onResume(owner: LifecycleOwner) { + // Config-change recovery: deliver any result cached while Activity was recreating + pendingLoginResult.getAndSet(null)?.let { pending -> + when (pending) { + is PendingResult.Success -> loginCallback.onSuccess(pending.result) + is PendingResult.Failure -> loginCallback.onFailure(pending.error) + } + resetManagerInstance() + } + pendingLogoutResult.getAndSet(null)?.let { pending -> + when (pending) { + is PendingResult.Success -> logoutCallback.onSuccess(pending.result) + is PendingResult.Failure -> logoutCallback.onFailure(pending.error) + } + resetManagerInstance() + } + } + + override fun onDestroy(owner: LifecycleOwner) { + callbacks -= loginCallback + owner.lifecycle.removeObserver(this) + } + }) + } + + @Deprecated( + message = "Use registerCallbacks() instead — it registers the callback and auto-removes it when the lifecycle owner is destroyed.", + replaceWith = ReplaceWith("registerCallbacks(lifecycleOwner, loginCallback = callback, logoutCallback = logoutCallback)") + ) @JvmStatic public fun addCallback(callback: Callback) { callbacks += callback } + @Deprecated( + message = "Use registerCallbacks() instead — it auto-removes the callback when the lifecycle owner is destroyed.", + replaceWith = ReplaceWith("registerCallbacks(lifecycleOwner, loginCallback = callback, logoutCallback = logoutCallback)") + ) @JvmStatic public fun removeCallback(callback: Callback) { callbacks -= callback @@ -142,7 +227,7 @@ public object WebAuthProvider : SenderConstraining { } } - internal fun onRestoreInstanceState(bundle: Bundle) { + internal fun onRestoreInstanceState(bundle: Bundle, context: Context) { if (managerInstance == null) { val oauthStateJson = bundle.getString(KEY_BUNDLE_OAUTH_MANAGER_STATE).orEmpty() val parStateJson = bundle.getString(KEY_BUNDLE_PAR_MANAGER_STATE).orEmpty() @@ -152,17 +237,26 @@ public object WebAuthProvider : SenderConstraining { state, object : Callback { override fun onSuccess(result: Credentials) { - for (callback in callbacks) { - callback.onSuccess(result) + if (callbacks.isNotEmpty()) { + for (callback in callbacks) { + callback.onSuccess(result) + } + } else { + pendingLoginResult.set(PendingResult.Success(result)) } } override fun onFailure(error: AuthenticationException) { - for (callback in callbacks) { - callback.onFailure(error) + if (callbacks.isNotEmpty()) { + for (callback in callbacks) { + callback.onFailure(error) + } + } else { + pendingLoginResult.set(PendingResult.Failure(error)) } } - } + }, + context ) } else if (parStateJson.isNotBlank()) { val state = PARCodeManagerState.deserializeState(parStateJson) @@ -192,6 +286,33 @@ public object WebAuthProvider : SenderConstraining { managerInstance = null } + @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) + internal fun resetState() { + managerInstance = null + callbacks.clear() + pendingLoginResult.set(null) + pendingLogoutResult.set(null) + } + + @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) + internal fun setPendingLoginResult(credentials: Credentials) { + pendingLoginResult.set(PendingResult.Success(credentials)) + } + + @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) + internal fun setPendingLogoutResult() { + pendingLogoutResult.set(PendingResult.Success(null)) + } + + @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) + internal fun hasPendingLoginResult(): Boolean = pendingLoginResult.get() != null + + @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) + internal fun hasPendingLogoutResult(): Boolean = pendingLogoutResult.get() != null + + @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) + internal fun callbacksCount(): Int = callbacks.size + public class LogoutBuilder internal constructor(private val account: Auth0) { private var scheme = "https" private var returnToUrl: String? = null @@ -289,6 +410,28 @@ public object WebAuthProvider : SenderConstraining { * @see AuthenticationException.isAuthenticationCanceled */ public fun start(context: Context, callback: Callback) { + pendingLogoutResult.set(null) + pendingLoginResult.set(null) + + val effectiveCallback = if (context is LifecycleOwner) { + LifecycleAwareCallback( + delegateCallback = callback, + lifecycleOwner = context as LifecycleOwner, + onDetached = { _: Void?, error: AuthenticationException? -> + if (error != null) { + pendingLogoutResult.set(PendingResult.Failure(error)) + } else { + pendingLogoutResult.set(PendingResult.Success(null)) + } + } + ) + } else { + callback + } + startInternal(context, effectiveCallback) + } + + private fun startInternal(context: Context, callback: Callback) { resetManagerInstance() if (!ctOptions.hasCompatibleBrowser(context.packageManager)) { val ex = AuthenticationException( @@ -333,7 +476,7 @@ public object WebAuthProvider : SenderConstraining { ) { return withContext(coroutineContext) { suspendCancellableCoroutine { continuation -> - start(context, object : Callback { + startInternal(context, object : Callback { override fun onSuccess(result: Void?) { continuation.resume(Unit) } @@ -605,6 +748,38 @@ public object WebAuthProvider : SenderConstraining { public fun start( context: Context, callback: Callback + ) { + pendingLoginResult.set(null) + pendingLogoutResult.set(null) + val effectiveCallback = if (context is LifecycleOwner) { + LifecycleAwareCallback( + delegateCallback = callback, + lifecycleOwner = context as LifecycleOwner, + onDetached = { success: Credentials?, error: AuthenticationException? -> + if (callbacks.isNotEmpty()) { + if (success != null) { + for (cb in callbacks) { cb.onSuccess(success) } + } else if (error != null) { + for (cb in callbacks) { cb.onFailure(error) } + } + } else { + if (success != null) { + pendingLoginResult.set(PendingResult.Success(success)) + } else if (error != null) { + pendingLoginResult.set(PendingResult.Failure(error)) + } + } + } + ) + } else { + callback + } + startInternal(context, effectiveCallback) + } + + private fun startInternal( + context: Context, + callback: Callback ) { resetManagerInstance() if (!ctOptions.hasCompatibleBrowser(context.packageManager)) { @@ -671,7 +846,9 @@ public object WebAuthProvider : SenderConstraining { ): Credentials { return withContext(coroutineContext) { suspendCancellableCoroutine { continuation -> - start(context, object : Callback { + // Use startInternal directly — the anonymous callback captures only the + // coroutine continuation, not an Activity, so lifecycle wrapping is not needed + startInternal(context, object : Callback { override fun onSuccess(result: Credentials) { continuation.resume(result) } diff --git a/auth0/src/test/java/com/auth0/android/provider/OAuthManagerStateTest.kt b/auth0/src/test/java/com/auth0/android/provider/OAuthManagerStateTest.kt index e4ac82383..21ac39659 100644 --- a/auth0/src/test/java/com/auth0/android/provider/OAuthManagerStateTest.kt +++ b/auth0/src/test/java/com/auth0/android/provider/OAuthManagerStateTest.kt @@ -1,8 +1,16 @@ package com.auth0.android.provider +import android.content.Context import android.graphics.Color import com.auth0.android.Auth0 +import com.auth0.android.authentication.AuthenticationAPIClient +import com.auth0.android.authentication.AuthenticationException +import com.auth0.android.callback.Callback +import com.auth0.android.result.Credentials import com.nhaarman.mockitokotlin2.mock +import com.nhaarman.mockitokotlin2.whenever +import org.hamcrest.MatcherAssert.assertThat +import org.hamcrest.core.Is.`is` import org.junit.Assert import org.junit.Test import org.junit.runner.RunWith @@ -44,4 +52,138 @@ internal class OAuthManagerStateTest { Assert.assertEquals(1, deserializedState.idTokenVerificationLeeway) Assert.assertEquals("issuer", deserializedState.idTokenVerificationIssuer) } + + @Test + fun `serialize should persist dPoPEnabled flag as true`() { + val auth0 = Auth0.getInstance("clientId", "domain") + val state = OAuthManagerState( + auth0 = auth0, + parameters = mapOf("param1" to "value1"), + headers = mapOf("header1" to "value1"), + requestCode = 1, + ctOptions = CustomTabsOptions.newBuilder() + .showTitle(true) + .withBrowserPicker( + BrowserPicker.newBuilder().withAllowedPackages(emptyList()).build() + ) + .build(), + pkce = PKCE(mock(), "redirectUri", mapOf("header1" to "value1")), + idTokenVerificationLeeway = 1, + idTokenVerificationIssuer = "issuer", + dPoPEnabled = true + ) + + val json = state.serializeToJson() + + Assert.assertTrue(json.isNotBlank()) + Assert.assertTrue(json.contains("\"dPoPEnabled\":true")) + + val deserializedState = OAuthManagerState.deserializeState(json) + + Assert.assertTrue(deserializedState.dPoPEnabled) + } + + @Test + fun `serialize should persist dPoPEnabled flag as false by default`() { + val auth0 = Auth0.getInstance("clientId", "domain") + val state = OAuthManagerState( + auth0 = auth0, + parameters = mapOf("param1" to "value1"), + headers = mapOf("header1" to "value1"), + requestCode = 1, + ctOptions = CustomTabsOptions.newBuilder() + .showTitle(true) + .withBrowserPicker( + BrowserPicker.newBuilder().withAllowedPackages(emptyList()).build() + ) + .build(), + pkce = PKCE(mock(), "redirectUri", mapOf("header1" to "value1")), + idTokenVerificationLeeway = 1, + idTokenVerificationIssuer = "issuer" + ) + + val json = state.serializeToJson() + + val deserializedState = OAuthManagerState.deserializeState(json) + + Assert.assertFalse(deserializedState.dPoPEnabled) + } + + @Test + fun `deserialize should default dPoPEnabled to false when field is missing from JSON`() { + val auth0 = Auth0.getInstance("clientId", "domain") + val state = OAuthManagerState( + auth0 = auth0, + parameters = emptyMap(), + headers = emptyMap(), + requestCode = 0, + ctOptions = CustomTabsOptions.newBuilder() + .showTitle(true) + .withBrowserPicker( + BrowserPicker.newBuilder().withAllowedPackages(emptyList()).build() + ) + .build(), + pkce = PKCE(mock(), "redirectUri", emptyMap()), + idTokenVerificationLeeway = null, + idTokenVerificationIssuer = null + ) + + val json = state.serializeToJson() + // Remove the dPoPEnabled field to simulate legacy JSON + val legacyJson = json.replace(",\"dPoPEnabled\":false", "") + + val deserializedState = OAuthManagerState.deserializeState(legacyJson) + + Assert.assertFalse(deserializedState.dPoPEnabled) + } + + @Test + fun `fromState should re-enable DPoP on the restored PKCE's API client when dPoPEnabled is true`() { + val context = mock() + whenever(context.applicationContext).thenReturn(context) + val auth0 = Auth0.getInstance("clientId", "domain") + val apiClient = AuthenticationAPIClient(auth0) + val state = OAuthManagerState( + auth0 = auth0, + parameters = emptyMap(), + headers = emptyMap(), + requestCode = 0, + ctOptions = CustomTabsOptions.newBuilder().build(), + pkce = PKCE(apiClient, "codeVerifier", "redirectUri", "codeChallenge", emptyMap()), + idTokenVerificationLeeway = null, + idTokenVerificationIssuer = null, + dPoPEnabled = true + ) + val callback = mock>() + + OAuthManager.fromState(state, callback, context) + + // This is the actual regression guard: the token exchange after process death only + // includes the DPoP proof because fromState re-enables DPoP on the restored API client. + assertThat(apiClient.isDPoPEnabled, `is`(true)) + } + + @Test + fun `fromState should not enable DPoP on the restored PKCE's API client when dPoPEnabled is false`() { + val context = mock() + whenever(context.applicationContext).thenReturn(context) + val auth0 = Auth0.getInstance("clientId", "domain") + val apiClient = AuthenticationAPIClient(auth0) + val state = OAuthManagerState( + auth0 = auth0, + parameters = emptyMap(), + headers = emptyMap(), + requestCode = 0, + ctOptions = CustomTabsOptions.newBuilder().build(), + pkce = PKCE(apiClient, "codeVerifier", "redirectUri", "codeChallenge", emptyMap()), + idTokenVerificationLeeway = null, + idTokenVerificationIssuer = null, + dPoPEnabled = false + ) + val callback = mock>() + + OAuthManager.fromState(state, callback, context) + + assertThat(apiClient.isDPoPEnabled, `is`(false)) + } } diff --git a/auth0/src/test/java/com/auth0/android/provider/WebAuthProviderTest.kt b/auth0/src/test/java/com/auth0/android/provider/WebAuthProviderTest.kt index e033aa8df..79b2993dd 100644 --- a/auth0/src/test/java/com/auth0/android/provider/WebAuthProviderTest.kt +++ b/auth0/src/test/java/com/auth0/android/provider/WebAuthProviderTest.kt @@ -4,7 +4,11 @@ import android.app.Activity import android.content.Context import android.content.Intent import android.net.Uri +import android.os.Bundle import android.os.Parcelable +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleOwner import androidx.test.espresso.intent.matcher.IntentMatchers import androidx.test.espresso.intent.matcher.UriMatchers import com.auth0.android.Auth0 @@ -113,6 +117,9 @@ public class WebAuthProviderTest { ) `when`(mockKeyStore.hasKeyPair()).thenReturn(false) + + // Clear any state left over from previous tests + WebAuthProvider.resetState() } @@ -2958,6 +2965,31 @@ public class WebAuthProviderTest { mockAPI.shutdown() } + @Test + public fun shouldReEnableDPoPOnOAuthManagerAfterProcessDeathRestore() { + `when`(mockKeyStore.hasKeyPair()).thenReturn(true) + `when`(mockKeyStore.getKeyPair()).thenReturn(Pair(mock(), FakeECPublicKey())) + + WebAuthProvider.useDPoP(mockContext) + .login(account) + .start(activity, callback) + + val bundle = Bundle() + WebAuthProvider.onSaveInstanceState(bundle) + + // Simulate the host process being killed and recreated: the manager instance is gone, + // and the activity is recreated with the saved state. + WebAuthProvider.resetManagerInstance() + WebAuthProvider.onRestoreInstanceState(bundle, activity) + + val restoredManager = WebAuthProvider.managerInstance as OAuthManager + // This asserts the save/restore wiring reconstructs a DPoP-enabled manager. The actual + // regression guard — that DPoP is re-enabled on the restored PKCE's API client so the + // token exchange carries the proof — lives in OAuthManagerStateTest.fromState tests, + // since OAuthManager.pkce is private and not reachable here without reflection. + assertThat(restoredManager.dPoP, `is`(notNullValue())) + } + //** ** ** ** ** ** **// //** ** ** ** ** ** **// //** Helpers Functions**// @@ -3106,4 +3138,358 @@ public class WebAuthProviderTest { assertThat(codeCaptor.firstValue.code, `is`("test-code")) assertThat(codeCaptor.firstValue.state, `is`("test-state")) } -} \ No newline at end of file + + // --- LifecycleAwareCallback tests --- + + @Test + public fun shouldInvokeOnDetachedWithLoginResultAfterDestroy() { + val credentials = Mockito.mock(Credentials::class.java) + var capturedSuccess: Credentials? = null + var capturedError: AuthenticationException? = null + + val lifecycleOwner = Mockito.mock(LifecycleOwner::class.java) + val lifecycle = Mockito.mock(Lifecycle::class.java) + Mockito.`when`(lifecycleOwner.lifecycle).thenReturn(lifecycle) + + val lifecycleCallback = LifecycleAwareCallback( + delegateCallback = callback, + lifecycleOwner = lifecycleOwner, + onDetached = { success, error -> + capturedSuccess = success + capturedError = error + } + ) + + lifecycleCallback.onDestroy(lifecycleOwner) + lifecycleCallback.onSuccess(credentials) + + Assert.assertEquals(credentials, capturedSuccess) + Assert.assertNull(capturedError) + verify(callback, Mockito.never()).onSuccess(any()) + } + + @Test + public fun shouldInvokeOnDetachedWithLoginFailureAfterDestroy() { + val error = AuthenticationException("canceled", "User canceled") + var capturedSuccess: Credentials? = null + var capturedError: AuthenticationException? = null + + val lifecycleOwner = Mockito.mock(LifecycleOwner::class.java) + val lifecycle = Mockito.mock(Lifecycle::class.java) + Mockito.`when`(lifecycleOwner.lifecycle).thenReturn(lifecycle) + + val lifecycleCallback = LifecycleAwareCallback( + delegateCallback = callback, + lifecycleOwner = lifecycleOwner, + onDetached = { success, detachedError -> + capturedSuccess = success + capturedError = detachedError + } + ) + + lifecycleCallback.onDestroy(lifecycleOwner) + lifecycleCallback.onFailure(error) + + Assert.assertNull(capturedSuccess) + Assert.assertEquals(error, capturedError) + verify(callback, Mockito.never()).onFailure(any()) + } + + @Test + public fun shouldDeliverDirectlyWhenLifecycleCallbackIsAlive() { + val credentials = Mockito.mock(Credentials::class.java) + var onDetachedCalled = false + + val lifecycleOwner = Mockito.mock(LifecycleOwner::class.java) + val lifecycle = Mockito.mock(Lifecycle::class.java) + Mockito.`when`(lifecycleOwner.lifecycle).thenReturn(lifecycle) + + val lifecycleCallback = LifecycleAwareCallback( + delegateCallback = callback, + lifecycleOwner = lifecycleOwner, + onDetached = { _, _ -> onDetachedCalled = true } + ) + + lifecycleCallback.onSuccess(credentials) + + verify(callback).onSuccess(credentials) + Assert.assertFalse(onDetachedCalled) + } + + @Test + public fun shouldDeliverFailureDirectlyWhenLifecycleCallbackIsAlive() { + val error = AuthenticationException("canceled", "User canceled") + var onDetachedCalled = false + + val lifecycleOwner = Mockito.mock(LifecycleOwner::class.java) + val lifecycle = Mockito.mock(Lifecycle::class.java) + Mockito.`when`(lifecycleOwner.lifecycle).thenReturn(lifecycle) + + val lifecycleCallback = LifecycleAwareCallback( + delegateCallback = callback, + lifecycleOwner = lifecycleOwner, + onDetached = { _, _ -> onDetachedCalled = true } + ) + + lifecycleCallback.onFailure(error) + + verify(callback).onFailure(error) + Assert.assertFalse(onDetachedCalled) + } + + @Test + public fun shouldInvokeOnDetachedWithLogoutSuccessAfterDestroy() { + var onDetachedCalled = false + var capturedError: AuthenticationException? = null + + val lifecycleOwner = Mockito.mock(LifecycleOwner::class.java) + val lifecycle = Mockito.mock(Lifecycle::class.java) + Mockito.`when`(lifecycleOwner.lifecycle).thenReturn(lifecycle) + + val lifecycleCallback = LifecycleAwareCallback( + delegateCallback = voidCallback, + lifecycleOwner = lifecycleOwner, + onDetached = { _, error -> + onDetachedCalled = true + capturedError = error + } + ) + + lifecycleCallback.onDestroy(lifecycleOwner) + lifecycleCallback.onSuccess(null) + + Assert.assertTrue(onDetachedCalled) + Assert.assertNull(capturedError) + verify(voidCallback, Mockito.never()).onSuccess(any()) + } + + @Test + public fun shouldInvokeOnDetachedWithLogoutFailureAfterDestroy() { + val error = AuthenticationException("canceled", "User closed the browser") + var onDetachedCalled = false + var capturedError: AuthenticationException? = null + + val lifecycleOwner = Mockito.mock(LifecycleOwner::class.java) + val lifecycle = Mockito.mock(Lifecycle::class.java) + Mockito.`when`(lifecycleOwner.lifecycle).thenReturn(lifecycle) + + val lifecycleCallback = LifecycleAwareCallback( + delegateCallback = voidCallback, + lifecycleOwner = lifecycleOwner, + onDetached = { _, detachedError -> + onDetachedCalled = true + capturedError = detachedError + } + ) + + lifecycleCallback.onDestroy(lifecycleOwner) + lifecycleCallback.onFailure(error) + + Assert.assertTrue(onDetachedCalled) + Assert.assertEquals(error, capturedError) + verify(voidCallback, Mockito.never()).onFailure(any()) + } + + @Test + public fun shouldDeliverLogoutDirectlyWhenLifecycleCallbackIsAlive() { + var onDetachedCalled = false + + val lifecycleOwner = Mockito.mock(LifecycleOwner::class.java) + val lifecycle = Mockito.mock(Lifecycle::class.java) + Mockito.`when`(lifecycleOwner.lifecycle).thenReturn(lifecycle) + + val lifecycleCallback = LifecycleAwareCallback( + delegateCallback = voidCallback, + lifecycleOwner = lifecycleOwner, + onDetached = { _, _ -> onDetachedCalled = true } + ) + + lifecycleCallback.onSuccess(null) + + verify(voidCallback).onSuccess(null) + Assert.assertFalse(onDetachedCalled) + } + + @Test + public fun shouldRegisterAsLifecycleObserverOnInit() { + val lifecycleOwner = Mockito.mock(LifecycleOwner::class.java) + val lifecycle = Mockito.mock(Lifecycle::class.java) + Mockito.`when`(lifecycleOwner.lifecycle).thenReturn(lifecycle) + + val lifecycleCallback = LifecycleAwareCallback( + delegateCallback = callback, + lifecycleOwner = lifecycleOwner, + onDetached = { _, _ -> } + ) + + verify(lifecycle).addObserver(lifecycleCallback) + } + + @Test + public fun shouldUnregisterLifecycleObserverOnDestroy() { + val lifecycleOwner = Mockito.mock(LifecycleOwner::class.java) + val lifecycle = Mockito.mock(Lifecycle::class.java) + Mockito.`when`(lifecycleOwner.lifecycle).thenReturn(lifecycle) + + val lifecycleCallback = LifecycleAwareCallback( + delegateCallback = callback, + lifecycleOwner = lifecycleOwner, + onDetached = { _, _ -> } + ) + + lifecycleCallback.onDestroy(lifecycleOwner) + + verify(lifecycle).removeObserver(lifecycleCallback) + } + + + @Test + public fun shouldWrapCallbackWithLifecycleAwareCallbackWhenStartedWithLifecycleOwner() { + val lifecycle = Mockito.mock(Lifecycle::class.java) + val lifecycleActivity = Mockito.mock(TestLifecycleOwnerActivity::class.java) + Mockito.`when`(lifecycleActivity.lifecycle).thenReturn(lifecycle) + Mockito.`when`(lifecycleActivity.applicationContext).thenReturn(lifecycleActivity) + Mockito.`when`(lifecycleActivity.packageName).thenReturn("com.auth0.test") + Mockito.doReturn(false).`when`(lifecycleActivity).bindService(any(), any(), ArgumentMatchers.anyInt()) + BrowserPickerTest.setupBrowserContext(lifecycleActivity, listOf("com.auth0.browser"), null, null) + + login(account).start(lifecycleActivity, callback) + + verify(lifecycle).addObserver(any>()) + } + + + @Test + public fun shouldRegisterLifecycleObserverOnRegisterCallbacks() { + val lifecycleOwner = Mockito.mock(LifecycleOwner::class.java) + val lifecycle = Mockito.mock(Lifecycle::class.java) + Mockito.`when`(lifecycleOwner.lifecycle).thenReturn(lifecycle) + + WebAuthProvider.registerCallbacks(lifecycleOwner, loginCallback = callback, logoutCallback = voidCallback) + + verify(lifecycle).addObserver(isA()) + } + + @Test + public fun shouldRemoveLifecycleObserverOnDestroyAfterRegisterCallbacks() { + val lifecycleOwner = Mockito.mock(LifecycleOwner::class.java) + val lifecycle = Mockito.mock(Lifecycle::class.java) + Mockito.`when`(lifecycleOwner.lifecycle).thenReturn(lifecycle) + + val observerCaptor = argumentCaptor() + WebAuthProvider.registerCallbacks(lifecycleOwner, loginCallback = callback, logoutCallback = voidCallback) + verify(lifecycle).addObserver(observerCaptor.capture()) + + observerCaptor.firstValue.onDestroy(lifecycleOwner) + + verify(lifecycle).removeObserver(observerCaptor.firstValue) + } + + @Test + public fun shouldRemoveLoginCallbackFromCallbacksOnDestroy() { + val lifecycleOwner = Mockito.mock(LifecycleOwner::class.java) + val lifecycle = Mockito.mock(Lifecycle::class.java) + Mockito.`when`(lifecycleOwner.lifecycle).thenReturn(lifecycle) + + val observerCaptor = argumentCaptor() + WebAuthProvider.registerCallbacks(lifecycleOwner, loginCallback = callback, logoutCallback = voidCallback) + verify(lifecycle).addObserver(observerCaptor.capture()) + + Assert.assertEquals(1, WebAuthProvider.callbacksCount()) + + observerCaptor.firstValue.onDestroy(lifecycleOwner) + + Assert.assertEquals(0, WebAuthProvider.callbacksCount()) + } + + + @Test + public fun shouldDeliverPendingLoginResultOnResume() { + val credentials = Mockito.mock(Credentials::class.java) + WebAuthProvider.setPendingLoginResult(credentials) + + val lifecycleOwner = Mockito.mock(LifecycleOwner::class.java) + val lifecycle = Mockito.mock(Lifecycle::class.java) + Mockito.`when`(lifecycleOwner.lifecycle).thenReturn(lifecycle) + + val observerCaptor = argumentCaptor() + WebAuthProvider.registerCallbacks(lifecycleOwner, loginCallback = callback, logoutCallback = voidCallback) + verify(lifecycle).addObserver(observerCaptor.capture()) + + observerCaptor.firstValue.onResume(lifecycleOwner) + + verify(callback).onSuccess(credentials) + Assert.assertFalse(WebAuthProvider.hasPendingLoginResult()) + } + + @Test + public fun shouldDeliverPendingLogoutResultOnResume() { + WebAuthProvider.setPendingLogoutResult() + + val lifecycleOwner = Mockito.mock(LifecycleOwner::class.java) + val lifecycle = Mockito.mock(Lifecycle::class.java) + Mockito.`when`(lifecycleOwner.lifecycle).thenReturn(lifecycle) + + val observerCaptor = argumentCaptor() + WebAuthProvider.registerCallbacks(lifecycleOwner, loginCallback = callback, logoutCallback = voidCallback) + verify(lifecycle).addObserver(observerCaptor.capture()) + + observerCaptor.firstValue.onResume(lifecycleOwner) + + verify(voidCallback).onSuccess(null) + Assert.assertFalse(WebAuthProvider.hasPendingLogoutResult()) + } + + @Test + public fun shouldNotDeliverLoginResultOnResumeWhenNoPendingResult() { + val lifecycleOwner = Mockito.mock(LifecycleOwner::class.java) + val lifecycle = Mockito.mock(Lifecycle::class.java) + Mockito.`when`(lifecycleOwner.lifecycle).thenReturn(lifecycle) + + val observerCaptor = argumentCaptor() + WebAuthProvider.registerCallbacks(lifecycleOwner, loginCallback = callback, logoutCallback = voidCallback) + verify(lifecycle).addObserver(observerCaptor.capture()) + + observerCaptor.firstValue.onResume(lifecycleOwner) + verify(callback, Mockito.never()).onSuccess(any()) + verify(callback, Mockito.never()).onFailure(any()) + } + + @Test + public fun shouldNotDeliverLogoutResultOnResumeWhenNoPendingResult() { + val lifecycleOwner = Mockito.mock(LifecycleOwner::class.java) + val lifecycle = Mockito.mock(Lifecycle::class.java) + Mockito.`when`(lifecycleOwner.lifecycle).thenReturn(lifecycle) + + val observerCaptor = argumentCaptor() + WebAuthProvider.registerCallbacks(lifecycleOwner, loginCallback = callback, logoutCallback = voidCallback) + verify(lifecycle).addObserver(observerCaptor.capture()) + + observerCaptor.firstValue.onResume(lifecycleOwner) + verify(voidCallback, Mockito.never()).onSuccess(any()) + verify(voidCallback, Mockito.never()).onFailure(any()) + } + + @Test + public fun shouldClearPendingLoginResultOnNewLoginStart() { + val credentials = Mockito.mock(Credentials::class.java) + WebAuthProvider.setPendingLoginResult(credentials) + Assert.assertTrue(WebAuthProvider.hasPendingLoginResult()) + + login(account).start(activity, callback) + + Assert.assertFalse(WebAuthProvider.hasPendingLoginResult()) + } + + @Test + public fun shouldClearPendingLogoutResultOnNewLogoutStart() { + WebAuthProvider.setPendingLogoutResult() + Assert.assertTrue(WebAuthProvider.hasPendingLogoutResult()) + + logout(account).start(activity, voidCallback) + + Assert.assertFalse(WebAuthProvider.hasPendingLogoutResult()) + } +} + +internal abstract class TestLifecycleOwnerActivity : Activity(), LifecycleOwner diff --git a/sample/src/main/java/com/auth0/sample/DatabaseLoginFragment.kt b/sample/src/main/java/com/auth0/sample/DatabaseLoginFragment.kt index 918de531b..5e5372108 100644 --- a/sample/src/main/java/com/auth0/sample/DatabaseLoginFragment.kt +++ b/sample/src/main/java/com/auth0/sample/DatabaseLoginFragment.kt @@ -118,6 +118,17 @@ class DatabaseLoginFragment : Fragment() { } } + private val logoutCallback = object : Callback { + override fun onSuccess(result: Void?) { + Snackbar.make(requireView(), "Logged out", Snackbar.LENGTH_LONG).show() + } + + override fun onFailure(error: AuthenticationException) { + Snackbar.make(requireView(), error.getDescription(), Snackbar.LENGTH_LONG) + .show() + } + } + override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View { @@ -204,14 +215,15 @@ class DatabaseLoginFragment : Fragment() { return binding.root } - override fun onStart() { - super.onStart() - WebAuthProvider.addCallback(callback) - } - - override fun onStop() { - super.onStop() - WebAuthProvider.removeCallback(callback) + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + // Registers login + logout callbacks for the lifetime of this view. Covers + // process-death and configuration-change recovery; auto-removed on destroy. + WebAuthProvider.registerCallbacks( + viewLifecycleOwner, + loginCallback = callback, + logoutCallback = logoutCallback + ) } private suspend fun dbLoginAsync(email: String, password: String) { From 9ad8fe66adc2bdccd93bfad12847b2027c7f9b86 Mon Sep 17 00:00:00 2001 From: Prince Mathew <17837162+pmathew92@users.noreply.github.com> Date: Mon, 15 Jun 2026 15:17:40 +0530 Subject: [PATCH 40/60] Release 3.19.0 (#981) --- .version | 2 +- CHANGELOG.md | 9 +++++++++ README.md | 2 +- 3 files changed, 11 insertions(+), 2 deletions(-) diff --git a/.version b/.version index ae5615506..209f5795d 100644 --- a/.version +++ b/.version @@ -1 +1 @@ -3.18.0 \ No newline at end of file +3.19.0 \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 41a8fabb9..49f93ed1f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # Change Log +## [3.19.0](https://github.com/auth0/Auth0.Android/tree/3.19.0) (2026-06-15) +[Full Changelog](https://github.com/auth0/Auth0.Android/compare/3.18.0...3.19.0) + +**Added** +- feat: CTE delegation and impersonation support [\#975](https://github.com/auth0/Auth0.Android/pull/975) ([pmathew92](https://github.com/pmathew92)) + +**Fixed** +- fix : Handling DPoP enabled WebAuth flow after process death [\#977](https://github.com/auth0/Auth0.Android/pull/977) ([pmathew92](https://github.com/pmathew92)) + ## [3.18.0](https://github.com/auth0/Auth0.Android/tree/3.18.0) (2026-05-26) [Full Changelog](https://github.com/auth0/Auth0.Android/compare/3.17.0...3.18.0) diff --git a/README.md b/README.md index df2196e18..b8e09bfbd 100644 --- a/README.md +++ b/README.md @@ -60,7 +60,7 @@ To install Auth0.Android with [Gradle](https://gradle.org/), simply add the foll ```gradle dependencies { - implementation 'com.auth0.android:auth0:3.18.0' + implementation 'com.auth0.android:auth0:3.19.0' } ``` From 06ec2a6920e2012ee92ab050de6d9d917b16a13f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 17 Jun 2026 14:10:50 +0530 Subject: [PATCH 41/60] chore(deps): bump actions/setup-java from 5.2.0 to 5.3.0 (#982) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/codeql.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index ddce02047..b3d19ecff 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -36,7 +36,7 @@ jobs: run: exit 0 # Skip unnecessary test runs for dependabot and merge queues. Artifically flag as successful, as this is a required check for branch protection. - name: Set up Java - uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0 + uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5.3.0 with: distribution: 'temurin' java-version: '11' From 06163473bf010047c679869bf68f281080a34507 Mon Sep 17 00:00:00 2001 From: utkrishtS Date: Thu, 18 Jun 2026 06:57:51 +0530 Subject: [PATCH 42/60] feat: enforce IPSIE session_expiry ceiling in credentials managers --- EXAMPLES.md | 27 +++ .../storage/BaseCredentialsManager.kt | 74 ++++++++ .../storage/CredentialsManager.kt | 26 ++- .../storage/CredentialsManagerException.kt | 5 + .../storage/SecureCredentialsManager.kt | 36 +++- .../com/auth0/android/request/internal/Jwt.kt | 8 + .../com/auth0/android/result/Credentials.kt | 14 ++ .../storage/CredentialsManagerTest.kt | 176 ++++++++++++++++++ .../storage/SecureCredentialsManagerTest.kt | 126 +++++++++++++ .../auth0/android/request/internal/JwtTest.kt | 50 +++++ .../auth0/android/result/CredentialsTest.kt | 38 ++++ 11 files changed, 574 insertions(+), 6 deletions(-) diff --git a/EXAMPLES.md b/EXAMPLES.md index 1520f3cff..1c617d7af 100644 --- a/EXAMPLES.md +++ b/EXAMPLES.md @@ -2886,6 +2886,7 @@ In the event that something happened while trying to save or retrieve the creden - **DPoP key pair lost** — The DPoP key pair is no longer available in the Android KeyStore. The stored credentials are cleared and re-authentication is required. - **DPoP key pair mismatch** — The DPoP key pair exists but is different from the one used when the credentials were saved. The stored credentials are cleared and re-authentication is required. - **DPoP not configured** — The stored credentials are DPoP-bound but the `AuthenticationAPIClient` used by the credentials manager was not configured with `useDPoP(context)`. The developer needs to call `AuthenticationAPIClient(auth0).useDPoP(context)` and pass the configured client to the credentials manager. +- **Session expired** — The session has reached the `session_expiry` ceiling asserted by the upstream identity provider. The stored credentials are cleared and re-authentication is required. See [Upstream session expiry](#upstream-session-expiry) below. You can access the `code` property of the `CredentialsManagerException` to understand why the operation with `CredentialsManager` has failed and the `message` property of the `CredentialsManagerException` would give you a description of the exception. @@ -2919,10 +2920,36 @@ when(credentialsManagerException) { // Developer forgot to call useDPoP() on the AuthenticationAPIClient // passed to the credentials manager. Fix the client configuration. } + + CredentialsManagerException.SESSION_EXPIRED -> { + // The upstream identity provider's session_expiry ceiling was reached. + // The stored credentials have already been cleared; prompt the user to + // re-authenticate. + } // ... similarly for other error codes } ``` +### Upstream session expiry + +When an enterprise connection (for example an OIDC or Okta connection) is configured to assert a session lifetime, Auth0 includes a `session_expiry` claim in the ID token. This claim is an absolute ceiling — expressed in **Unix seconds** — on how long the local session may live, independently of the access-token expiry. It usually sits much further out than `expiresAt`, and it cannot be extended by a refresh-token renewal. + +The credentials managers enforce this ceiling automatically: + +- The ceiling is read from the ID token at login and persisted, so it survives refreshes whose ID token does not re-emit the claim. +- On every `getCredentials` call, if the ceiling has been reached the stored credentials are cleared and the call fails with `CredentialsManagerException.SESSION_EXPIRED`. The refresh token is **never** used to renew a session past the ceiling. +- A small negative clock-skew leeway (~30 seconds) is applied, so the session is treated as expired slightly *before* the wall-clock ceiling, never after. +- Connections that do not emit the claim are unaffected — there is no ceiling and behavior is unchanged. + +> ⚠️ **Upgrade note:** For a user whose connection asserts `session_expiry`, a `getCredentials` call that previously succeeded can now fail with `SESSION_EXPIRED` once the ceiling is reached. Make sure your error handling treats `SESSION_EXPIRED` as a prompt to re-authenticate. + +You can read the ceiling for a given credential set from `Credentials.sessionExpiresAt` (a nullable `Long` of Unix seconds, `null` when the connection does not emit the claim): + +```kotlin +val credentials = credentialsManager.awaitCredentials() +val ceiling: Long? = credentials.sessionExpiresAt +``` + ## Passkeys User should have a custom domain configured and passkey grant-type enabled in the Auth0 dashboard to use passkeys. diff --git a/auth0/src/main/java/com/auth0/android/authentication/storage/BaseCredentialsManager.kt b/auth0/src/main/java/com/auth0/android/authentication/storage/BaseCredentialsManager.kt index c3ce2133a..9be239d25 100644 --- a/auth0/src/main/java/com/auth0/android/authentication/storage/BaseCredentialsManager.kt +++ b/auth0/src/main/java/com/auth0/android/authentication/storage/BaseCredentialsManager.kt @@ -28,6 +28,19 @@ public abstract class BaseCredentialsManager internal constructor( @VisibleForTesting(otherwise = VisibleForTesting.PACKAGE_PRIVATE) internal const val KEY_TOKEN_TYPE = "com.auth0.token_type" + + /** + * Storage key for the IPSIE `session_expiry` ceiling (Unix seconds), persisted at login so it + * survives a refresh whose ID token does not re-emit the claim. See [isSessionExpired]. + */ + @VisibleForTesting(otherwise = VisibleForTesting.PACKAGE_PRIVATE) + internal const val KEY_SESSION_EXPIRY = "com.auth0.session_expiry" + + /** + * Negative clock-skew leeway (seconds) applied when checking the `session_expiry` ceiling, so + * the session is treated as expired slightly *before* the wall-clock ceiling, never after. + */ + private const val SESSION_EXPIRY_LEEWAY_SECONDS = 30L } private var _clock: Clock = ClockImpl() @@ -302,6 +315,67 @@ public abstract class BaseCredentialsManager internal constructor( return expiresAt <= currentTimeInMillis } + /** + * Reads the IPSIE `session_expiry` ceiling (Unix seconds) from the given ID token, or null when + * the token is absent/unparseable or does not carry the claim. + */ + private fun sessionExpiryFromIdToken(idToken: String?): Long? { + if (idToken.isNullOrBlank()) return null + return runCatching { jwtDecoder.decode(idToken).sessionExpiry }.getOrNull() + } + + /** + * Checks whether the upstream-IdP session ceiling (`session_expiry`) has been reached. + * + * The ceiling is resolved in order: (1) the live claim in [idToken]; (2) the value persisted at + * login under [KEY_SESSION_EXPIRY] (so a refresh whose ID token omits the claim does not silently + * drop the ceiling); (3) if neither is present there is no ceiling and the session is NOT expired + * — a missing value must fall through to existing behavior, never be treated as already-expired. + * + * A small negative clock-skew leeway is applied so the session is treated as expired slightly + * before the wall-clock ceiling, never after. + */ + protected fun isSessionExpired(idToken: String?): Boolean { + val sessionExpiry = sessionExpiryFromIdToken(idToken) + ?: storage.retrieveLong(KEY_SESSION_EXPIRY) + ?: return false + // A non-positive ceiling is not a valid Unix timestamp; treat it as "no ceiling" rather than + // already-expired (mirrors the guard in [willExpire] for unset/migration values). + if (sessionExpiry <= 0) { + return false + } + val nowSeconds = currentTimeInMillis / 1000 + return nowSeconds + SESSION_EXPIRY_LEEWAY_SECONDS >= sessionExpiry + } + + /** + * Persists the `session_expiry` ceiling read from the given ID token, if present. + * + * To preserve the ceiling across refreshes (the refresh grant does not re-emit `session_expiry`), + * the stored value is only ever written, never cleared, when a fresh ID token omits the claim. + * Call from `saveCredentials`. + */ + protected fun persistSessionExpiry(idToken: String?) { + sessionExpiryFromIdToken(idToken)?.let { storage.store(KEY_SESSION_EXPIRY, it) } + } + + /** + * Validates, at session-creation time, that the given ID token is not already past its + * `session_expiry` ceiling (i.e. `session_expiry <= iat`). Throws [CredentialsManagerException] + * with code `SESSION_EXPIRED` when it is, so an already-expired session is never persisted. + * No-op when the token is absent or does not carry both `session_expiry` and `iat`. + */ + @Throws(CredentialsManagerException::class) + protected fun validateSessionExpiryAtCreation(idToken: String?) { + if (idToken.isNullOrBlank()) return + val jwt = runCatching { jwtDecoder.decode(idToken) }.getOrNull() ?: return + val sessionExpiry = jwt.sessionExpiry ?: return + val issuedAtSeconds = jwt.issuedAt?.time?.div(1000) ?: return + if (sessionExpiry <= issuedAtSeconds) { + throw CredentialsManagerException.SESSION_EXPIRED + } + } + /** * Returns the key for storing the APICredentials in storage. Uses a combination of audience and scope. * 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 31c865f02..29499c27c 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 @@ -68,6 +68,8 @@ public class CredentialsManager @VisibleForTesting(otherwise = VisibleForTesting if (TextUtils.isEmpty(credentials.accessToken) && TextUtils.isEmpty(credentials.idToken)) { throw CredentialsManagerException.INVALID_CREDENTIALS } + // IPSIE session_expiry: reject a session already past its ceiling at creation time. + validateSessionExpiryAtCreation(credentials.idToken) storage.store(KEY_ACCESS_TOKEN, credentials.accessToken) storage.store(KEY_REFRESH_TOKEN, credentials.refreshToken) storage.store(KEY_ID_TOKEN, credentials.idToken) @@ -75,6 +77,9 @@ public class CredentialsManager @VisibleForTesting(otherwise = VisibleForTesting storage.store(KEY_EXPIRES_AT, credentials.expiresAt.time) storage.store(KEY_SCOPE, credentials.scope) storage.store(LEGACY_KEY_CACHE_EXPIRES_AT, credentials.expiresAt.time) + // Preserve the session_expiry ceiling across refreshes: only ever written, never cleared, + // so a refresh whose ID token omits the claim does not silently remove the limit. + persistSessionExpiry(credentials.idToken) saveDPoPThumbprint(credentials) } @@ -462,6 +467,14 @@ public class CredentialsManager @VisibleForTesting(otherwise = VisibleForTesting callback.onFailure(CredentialsManagerException.NO_CREDENTIALS) return@execute } + // IPSIE session_expiry: enforce the upstream-IdP session ceiling before serving any + // cached token or attempting a refresh. Past the ceiling, clear and surface the error + // so the refresh-token grant is never used to outlive the session. + if (isSessionExpired(idToken)) { + clearCredentials() + callback.onFailure(CredentialsManagerException.SESSION_EXPIRED) + return@execute + } val willAccessTokenExpire = willExpire(expiresAt!!, minTtl.toLong()) val scopeChanged = hasScopeChanged(storedScope, scope) if (!forceRefresh && !willAccessTokenExpire && !scopeChanged) { @@ -697,9 +710,15 @@ public class CredentialsManager @VisibleForTesting(otherwise = VisibleForTesting val expiresAt = storage.retrieveLong(KEY_EXPIRES_AT) val emptyCredentials = TextUtils.isEmpty(accessToken) && TextUtils.isEmpty(idToken) || expiresAt == null - return !(emptyCredentials || willExpire( - expiresAt!!, minTtl - ) && refreshToken == null) + if (emptyCredentials) { + return false + } + // IPSIE session_expiry: once the upstream-IdP ceiling passes, no valid credentials remain and + // a refresh cannot extend the session past it, so report no valid credentials. + if (isSessionExpired(idToken)) { + return false + } + return !(willExpire(expiresAt!!, minTtl) && refreshToken == null) } /** @@ -714,6 +733,7 @@ public class CredentialsManager @VisibleForTesting(otherwise = VisibleForTesting storage.remove(KEY_SCOPE) storage.remove(LEGACY_KEY_CACHE_EXPIRES_AT) storage.remove(KEY_DPOP_THUMBPRINT) + storage.remove(KEY_SESSION_EXPIRY) } /** diff --git a/auth0/src/main/java/com/auth0/android/authentication/storage/CredentialsManagerException.kt b/auth0/src/main/java/com/auth0/android/authentication/storage/CredentialsManagerException.kt index 44c31f7d8..681140489 100644 --- a/auth0/src/main/java/com/auth0/android/authentication/storage/CredentialsManagerException.kt +++ b/auth0/src/main/java/com/auth0/android/authentication/storage/CredentialsManagerException.kt @@ -51,6 +51,7 @@ public class CredentialsManagerException : DPOP_KEY_MISSING, DPOP_KEY_MISMATCH, DPOP_NOT_CONFIGURED, + SESSION_EXPIRED, UNKNOWN_ERROR } @@ -169,6 +170,9 @@ public class CredentialsManagerException : public val DPOP_NOT_CONFIGURED: CredentialsManagerException = CredentialsManagerException(Code.DPOP_NOT_CONFIGURED) + public val SESSION_EXPIRED: CredentialsManagerException = + CredentialsManagerException(Code.SESSION_EXPIRED) + public val UNKNOWN_ERROR: CredentialsManagerException = CredentialsManagerException(Code.UNKNOWN_ERROR) @@ -220,6 +224,7 @@ public class CredentialsManagerException : Code.DPOP_KEY_MISSING -> "The stored credentials are DPoP-bound but the DPoP key pair is no longer available in the Android KeyStore. Re-authentication is required." Code.DPOP_KEY_MISMATCH -> "The stored credentials are DPoP-bound but the current DPoP key pair does not match the one used when credentials were saved. Re-authentication is required." Code.DPOP_NOT_CONFIGURED -> "The stored credentials are DPoP-bound but the AuthenticationAPIClient used by this credentials manager was not configured with useDPoP(context). Call AuthenticationAPIClient(auth0).useDPoP(context) and pass the configured client to the credentials manager." + Code.SESSION_EXPIRED -> "The session has reached the session_expiry ceiling set by the identity provider and is no longer valid. The user must re-authenticate." Code.UNKNOWN_ERROR -> "An unknown error has occurred while fetching the token. Please check the error cause for more details." } } diff --git a/auth0/src/main/java/com/auth0/android/authentication/storage/SecureCredentialsManager.kt b/auth0/src/main/java/com/auth0/android/authentication/storage/SecureCredentialsManager.kt index a982e2634..ac8119648 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 @@ -177,6 +177,8 @@ public class SecureCredentialsManager @VisibleForTesting(otherwise = VisibleForT if (TextUtils.isEmpty(credentials.accessToken) && TextUtils.isEmpty(credentials.idToken)) { throw CredentialsManagerException.INVALID_CREDENTIALS } + // IPSIE session_expiry: reject a session already past its ceiling at creation time. + validateSessionExpiryAtCreation(credentials.idToken) val json = gson.toJson(credentials) val canRefresh = !TextUtils.isEmpty(credentials.refreshToken) Log.d(TAG, "Trying to encrypt the given data using the private key.") @@ -190,6 +192,9 @@ public class SecureCredentialsManager @VisibleForTesting(otherwise = VisibleForT storage.store(LEGACY_KEY_CACHE_EXPIRES_AT, credentials.expiresAt.time) storage.store(KEY_CAN_REFRESH, canRefresh) storage.store(KEY_TOKEN_TYPE, credentials.type) + // Preserve the session_expiry ceiling across refreshes: only ever written, never cleared, + // so a refresh whose ID token omits the claim does not silently remove the limit. + persistSessionExpiry(credentials.idToken) saveDPoPThumbprint(credentials) } catch (e: IncompatibleDeviceException) { throw CredentialsManagerException( @@ -650,6 +655,15 @@ public class SecureCredentialsManager @VisibleForTesting(otherwise = VisibleForT forceRefresh: Boolean, callback: Callback ) { + // IPSIE session_expiry: short-circuit before any biometric prompt or refresh. The ceiling is + // read from the value persisted at login (KEY_SESSION_EXPIRY); past it we clear and surface the + // dedicated error rather than prompting biometrics for a session that can no longer be served. + if (isSessionExpired(null)) { + clearCredentials() + callback.onFailure(CredentialsManagerException.SESSION_EXPIRED) + return + } + if (!hasValidCredentials(minTtl.toLong())) { callback.onFailure(CredentialsManagerException.NO_CREDENTIALS) return @@ -736,6 +750,7 @@ public class SecureCredentialsManager @VisibleForTesting(otherwise = VisibleForT storage.remove(KEY_CAN_REFRESH) storage.remove(KEY_TOKEN_TYPE) storage.remove(KEY_DPOP_THUMBPRINT) + storage.remove(KEY_SESSION_EXPIRY) clearBiometricSession() Log.d(TAG, "Credentials were just removed from the storage") } @@ -775,9 +790,16 @@ public class SecureCredentialsManager @VisibleForTesting(otherwise = VisibleForT } val canRefresh = storage.retrieveBoolean(KEY_CAN_REFRESH) val emptyCredentials = TextUtils.isEmpty(encryptedEncoded) - return !(emptyCredentials || willExpire( - expiresAt, minTtl - ) && (canRefresh == null || !canRefresh)) + if (emptyCredentials) { + return false + } + // IPSIE session_expiry: once the upstream-IdP ceiling passes, no valid credentials remain. + // The credentials blob is encrypted and cannot be decoded here, so the ceiling is read from + // the value persisted at login (KEY_SESSION_EXPIRY) via isSessionExpired(null). + if (isSessionExpired(null)) { + return false + } + return !(willExpire(expiresAt, minTtl) && (canRefresh == null || !canRefresh)) } @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) @@ -833,6 +855,14 @@ public class SecureCredentialsManager @VisibleForTesting(otherwise = VisibleForT callback.onFailure(CredentialsManagerException.NO_CREDENTIALS) return@execute } + // IPSIE session_expiry: enforce the upstream-IdP session ceiling before serving any + // cached token or attempting a refresh. Past the ceiling, clear and surface the error + // so the refresh-token grant is never used to outlive the session. + if (isSessionExpired(credentials.idToken)) { + clearCredentials() + callback.onFailure(CredentialsManagerException.SESSION_EXPIRED) + return@execute + } val willAccessTokenExpire = willExpire(expiresAt, minTtl.toLong()) val scopeChanged = hasScopeChanged(credentials.scope, scope) if (!forceRefresh && !willAccessTokenExpire && !scopeChanged) { diff --git a/auth0/src/main/java/com/auth0/android/request/internal/Jwt.kt b/auth0/src/main/java/com/auth0/android/request/internal/Jwt.kt index 09558f650..9c5f80d27 100644 --- a/auth0/src/main/java/com/auth0/android/request/internal/Jwt.kt +++ b/auth0/src/main/java/com/auth0/android/request/internal/Jwt.kt @@ -30,6 +30,13 @@ internal class Jwt(rawToken: String) { val authenticationTime: Date? val audience: List + /** + * The IPSIE `session_expiry` claim: an absolute session-expiry ceiling in **Unix seconds** + * asserted by the upstream identity provider. Null when the connection does not emit the claim, + * which MUST be treated as "no ceiling". + */ + val sessionExpiry: Long? + init { parts = splitToken(rawToken) val jsonHeader = decodeBase64(parts[0]) @@ -53,6 +60,7 @@ internal class Jwt(rawToken: String) { authorizedParty = decodedPayload["azp"] as String? authenticationTime = (decodedPayload["auth_time"] as? Double)?.let { Date(it.toLong() * 1000) } + sessionExpiry = (decodedPayload["session_expiry"] as? Double)?.toLong() audience = when (val aud = decodedPayload["aud"]) { is String -> listOf(aud) is List<*> -> aud as List diff --git a/auth0/src/main/java/com/auth0/android/result/Credentials.kt b/auth0/src/main/java/com/auth0/android/result/Credentials.kt index ec32ed1f3..86dbf8965 100755 --- a/auth0/src/main/java/com/auth0/android/result/Credentials.kt +++ b/auth0/src/main/java/com/auth0/android/result/Credentials.kt @@ -79,4 +79,18 @@ public data class Credentials( return gson.fromJson(Jwt.decodeBase64(payload), UserProfile::class.java) } + /** + * The absolute session-expiry ceiling, in **Unix seconds**, asserted by the upstream identity + * provider via the IPSIE `session_expiry` claim in the ID token, or `null` when the connection + * does not emit the claim. + * + * This is a session-level ceiling that is independent of [expiresAt] (the access-token expiry): + * it usually sits much further out and caps how long the local session may live, regardless of + * access-token renewals. A `null` value means there is no such ceiling. + * + * The value is decoded on demand from [idToken] and is not stored as a separate field. + */ + public val sessionExpiresAt: Long? + get() = runCatching { Jwt(idToken).sessionExpiry }.getOrNull() + } \ No newline at end of file diff --git a/auth0/src/test/java/com/auth0/android/authentication/storage/CredentialsManagerTest.kt b/auth0/src/test/java/com/auth0/android/authentication/storage/CredentialsManagerTest.kt index c0e40a29d..2a81b7373 100644 --- a/auth0/src/test/java/com/auth0/android/authentication/storage/CredentialsManagerTest.kt +++ b/auth0/src/test/java/com/auth0/android/authentication/storage/CredentialsManagerTest.kt @@ -1521,6 +1521,7 @@ public class CredentialsManagerTest { verify(storage).remove("com.auth0.scope") verify(storage).remove("com.auth0.cache_expires_at") verify(storage).remove("com.auth0.dpop_key_thumbprint") + verify(storage).remove("com.auth0.session_expiry") verifyNoMoreInteractions(storage) } @@ -1812,6 +1813,20 @@ public class CredentialsManagerTest { Assert.assertFalse(manager.hasValidCredentials()) } + @Test + public fun shouldNotHaveCredentialsWhenSessionCeilingReachedEvenWithRefreshToken() { + val nowSeconds = CredentialsMock.CURRENT_TIME_MS / 1000 + // Access token still valid and a refresh token is present, so absent the ceiling this would + // report valid credentials; the breached session_expiry ceiling must override that. + Mockito.`when`(storage.retrieveLong("com.auth0.expires_at")) + .thenReturn(CredentialsMock.ONE_HOUR_AHEAD_MS) + Mockito.`when`(storage.retrieveString("com.auth0.refresh_token")).thenReturn("refreshToken") + Mockito.`when`(storage.retrieveString("com.auth0.id_token")).thenReturn("idToken") + Mockito.`when`(storage.retrieveString("com.auth0.access_token")).thenReturn("accessToken") + Mockito.`when`(storage.retrieveLong("com.auth0.session_expiry")).thenReturn(nowSeconds - 100) + Assert.assertFalse(manager.hasValidCredentials()) + } + @Test public fun shouldRecreateTheCredentials() { val credentialsManager = CredentialsManager(client, storage) @@ -2528,9 +2543,170 @@ public class CredentialsManagerTest { DPoPUtil.keyStore = DPoPKeyStore() } + // IPSIE session_expiry enforcement + + @Test + public fun shouldFailWithSessionExpiredAndNotRenewWhenSessionCeilingReached() { + val nowSeconds = CredentialsMock.CURRENT_TIME_MS / 1000 + Mockito.`when`(storage.retrieveString("com.auth0.id_token")).thenReturn("idToken") + Mockito.`when`(storage.retrieveString("com.auth0.access_token")).thenReturn("accessToken") + Mockito.`when`(storage.retrieveString("com.auth0.refresh_token")).thenReturn("refreshToken") + Mockito.`when`(storage.retrieveString("com.auth0.token_type")).thenReturn("type") + // Access token already expired so, absent the ceiling, a refresh would otherwise be attempted. + Mockito.`when`(storage.retrieveLong("com.auth0.expires_at")) + .thenReturn(CredentialsMock.CURRENT_TIME_MS) + Mockito.`when`(storage.retrieveString("com.auth0.scope")).thenReturn("scope") + prepareJwtDecoderMockWithSessionExpiry(nowSeconds - 100) + + manager.getCredentials(callback) + + verify(callback).onFailure(exceptionCaptor.capture()) + MatcherAssert.assertThat( + exceptionCaptor.firstValue, + Is.`is`(CredentialsManagerException.SESSION_EXPIRED) + ) + // The refresh-token grant must never be used past the session ceiling. + verifyZeroInteractions(client) + // The breached session must be cleared. + verify(storage).remove("com.auth0.session_expiry") + verify(storage).remove("com.auth0.id_token") + } + + @Test + public fun shouldFailWithSessionExpiredWhenCeilingFallsWithinLeeway() { + val nowSeconds = CredentialsMock.CURRENT_TIME_MS / 1000 + Mockito.`when`(storage.retrieveString("com.auth0.id_token")).thenReturn("idToken") + Mockito.`when`(storage.retrieveString("com.auth0.access_token")).thenReturn("accessToken") + Mockito.`when`(storage.retrieveString("com.auth0.refresh_token")).thenReturn("refreshToken") + Mockito.`when`(storage.retrieveString("com.auth0.token_type")).thenReturn("type") + Mockito.`when`(storage.retrieveLong("com.auth0.expires_at")) + .thenReturn(CredentialsMock.ONE_HOUR_AHEAD_MS) + Mockito.`when`(storage.retrieveString("com.auth0.scope")).thenReturn("scope") + // 10s in the future, but inside the 30s negative leeway -> treated as expired. + prepareJwtDecoderMockWithSessionExpiry(nowSeconds + 10) + + manager.getCredentials(callback) + + verify(callback).onFailure(exceptionCaptor.capture()) + MatcherAssert.assertThat( + exceptionCaptor.firstValue, + Is.`is`(CredentialsManagerException.SESSION_EXPIRED) + ) + verifyZeroInteractions(client) + } + + @Test + public fun shouldReturnCachedCredentialsWhenSessionCeilingNotReached() { + val nowSeconds = CredentialsMock.CURRENT_TIME_MS / 1000 + Mockito.`when`(storage.retrieveString("com.auth0.id_token")).thenReturn("idToken") + Mockito.`when`(storage.retrieveString("com.auth0.access_token")).thenReturn("accessToken") + Mockito.`when`(storage.retrieveString("com.auth0.refresh_token")).thenReturn("refreshToken") + Mockito.`when`(storage.retrieveString("com.auth0.token_type")).thenReturn("type") + // Access token not yet expired -> cached credentials are served without a refresh. + Mockito.`when`(storage.retrieveLong("com.auth0.expires_at")) + .thenReturn(CredentialsMock.ONE_HOUR_AHEAD_MS) + Mockito.`when`(storage.retrieveString("com.auth0.scope")).thenReturn("scope") + prepareJwtDecoderMockWithSessionExpiry(nowSeconds + 100_000) + + manager.getCredentials(callback) + + verify(callback).onSuccess(credentialsCaptor.capture()) + MatcherAssert.assertThat(credentialsCaptor.firstValue, Is.`is`(Matchers.notNullValue())) + verifyZeroInteractions(client) + } + + @Test + public fun shouldNotEnforceSessionExpiryWhenClaimAndStoredValueAreAbsent() { + Mockito.`when`(storage.retrieveString("com.auth0.id_token")).thenReturn("idToken") + Mockito.`when`(storage.retrieveString("com.auth0.access_token")).thenReturn("accessToken") + Mockito.`when`(storage.retrieveString("com.auth0.refresh_token")).thenReturn("refreshToken") + Mockito.`when`(storage.retrieveString("com.auth0.token_type")).thenReturn("type") + Mockito.`when`(storage.retrieveLong("com.auth0.expires_at")) + .thenReturn(CredentialsMock.ONE_HOUR_AHEAD_MS) + Mockito.`when`(storage.retrieveString("com.auth0.scope")).thenReturn("scope") + // No session_expiry claim and no stored ceiling -> existing behavior, no regression. + prepareJwtDecoderMockWithSessionExpiry(null) + Mockito.`when`(storage.retrieveLong("com.auth0.session_expiry")).thenReturn(null) + + manager.getCredentials(callback) + + verify(callback).onSuccess(credentialsCaptor.capture()) + MatcherAssert.assertThat(credentialsCaptor.firstValue, Is.`is`(Matchers.notNullValue())) + } + + @Test + public fun shouldUseStoredSessionExpiryWhenFreshIdTokenOmitsClaim() { + val nowSeconds = CredentialsMock.CURRENT_TIME_MS / 1000 + Mockito.`when`(storage.retrieveString("com.auth0.id_token")).thenReturn("idToken") + Mockito.`when`(storage.retrieveString("com.auth0.access_token")).thenReturn("accessToken") + Mockito.`when`(storage.retrieveString("com.auth0.refresh_token")).thenReturn("refreshToken") + Mockito.`when`(storage.retrieveString("com.auth0.token_type")).thenReturn("type") + Mockito.`when`(storage.retrieveLong("com.auth0.expires_at")) + .thenReturn(CredentialsMock.CURRENT_TIME_MS) + Mockito.`when`(storage.retrieveString("com.auth0.scope")).thenReturn("scope") + // The (refreshed) ID token does not re-emit the claim, but the ceiling persisted at login still applies. + prepareJwtDecoderMockWithSessionExpiry(null) + Mockito.`when`(storage.retrieveLong("com.auth0.session_expiry")).thenReturn(nowSeconds - 100) + + manager.getCredentials(callback) + + verify(callback).onFailure(exceptionCaptor.capture()) + MatcherAssert.assertThat( + exceptionCaptor.firstValue, + Is.`is`(CredentialsManagerException.SESSION_EXPIRED) + ) + verifyZeroInteractions(client) + } + + @Test + public fun shouldThrowWhenSavingCredentialsAlreadyPastSessionCeiling() { + val credentials: Credentials = CredentialsMock.create( + "idToken", "accessToken", "type", "refreshToken", + Date(CredentialsMock.ONE_HOUR_AHEAD_MS), "scope" + ) + val jwtMock = mock() + // session_expiry (1000) is at/below iat (2000) -> already expired at creation. + Mockito.`when`(jwtMock.sessionExpiry).thenReturn(1000L) + Mockito.`when`(jwtMock.issuedAt).thenReturn(Date(2000L * 1000)) + Mockito.`when`(jwtDecoder.decode("idToken")).thenReturn(jwtMock) + + val exception = assertThrows(CredentialsManagerException::class.java) { + manager.saveCredentials(credentials) + } + MatcherAssert.assertThat(exception, Is.`is`(CredentialsManagerException.SESSION_EXPIRED)) + verify(storage, never()).store(eq("com.auth0.id_token"), ArgumentMatchers.anyString()) + } + + @Test + public fun shouldPersistSessionExpiryWhenSavingCredentials() { + val credentials: Credentials = CredentialsMock.create( + "idToken", "accessToken", "type", "refreshToken", + Date(CredentialsMock.ONE_HOUR_AHEAD_MS), "scope" + ) + val sessionExpiry = (CredentialsMock.ONE_HOUR_AHEAD_MS / 1000) + 100_000 + val jwtMock = mock() + Mockito.`when`(jwtMock.sessionExpiry).thenReturn(sessionExpiry) + Mockito.`when`(jwtMock.issuedAt).thenReturn(Date(CredentialsMock.CURRENT_TIME_MS)) + Mockito.`when`(jwtMock.expiresAt).thenReturn(Date(CredentialsMock.ONE_HOUR_AHEAD_MS)) + Mockito.`when`(jwtDecoder.decode("idToken")).thenReturn(jwtMock) + + manager.saveCredentials(credentials) + + verify(storage).store("com.auth0.session_expiry", sessionExpiry) + } + + private fun prepareJwtDecoderMockWithSessionExpiry(sessionExpiry: Long?) { + val jwtMock = mock() + Mockito.`when`(jwtMock.sessionExpiry).thenReturn(sessionExpiry) + Mockito.`when`(jwtDecoder.decode("idToken")).thenReturn(jwtMock) + } + private fun prepareJwtDecoderMock(expiresAt: Date?) { val jwtMock = mock() Mockito.`when`(jwtMock.expiresAt).thenReturn(expiresAt) + // Default to a token without the IPSIE session_expiry claim. Mockito returns 0 (not null) for + // an unstubbed Long?-returning property, which would otherwise trigger a spurious persist. + Mockito.`when`(jwtMock.sessionExpiry).thenReturn(null) Mockito.`when`(jwtDecoder.decode("idToken")).thenReturn(jwtMock) } diff --git a/auth0/src/test/java/com/auth0/android/authentication/storage/SecureCredentialsManagerTest.kt b/auth0/src/test/java/com/auth0/android/authentication/storage/SecureCredentialsManagerTest.kt index fde98151c..a3edb5d9d 100644 --- a/auth0/src/test/java/com/auth0/android/authentication/storage/SecureCredentialsManagerTest.kt +++ b/auth0/src/test/java/com/auth0/android/authentication/storage/SecureCredentialsManagerTest.kt @@ -2176,6 +2176,7 @@ public class SecureCredentialsManagerTest { verify(storage).remove("com.auth0.credentials_can_refresh") verify(storage).remove("com.auth0.token_type") verify(storage).remove("com.auth0.dpop_key_thumbprint") + verify(storage).remove("com.auth0.session_expiry") verifyNoMoreInteractions(storage) } @@ -3571,6 +3572,128 @@ public class SecureCredentialsManagerTest { /** * Used to simplify the tests length */ + // IPSIE session_expiry enforcement + + @Test + public fun shouldFailWithSessionExpiredWithoutBiometricsOrRenewWhenStoredCeilingReached() { + Mockito.`when`(localAuthenticationManager.authenticate()).then { + localAuthenticationManager.resultCallback.onSuccess(true) + } + verifyNoMoreInteractions(client) + val nowSeconds = CredentialsMock.CURRENT_TIME_MS / 1000 + // Stored ceiling already in the past. + Mockito.`when`(storage.retrieveLong("com.auth0.session_expiry")).thenReturn(nowSeconds - 100) + + manager.getCredentials(callback) + + verify(callback).onFailure(exceptionCaptor.capture()) + MatcherAssert.assertThat( + exceptionCaptor.firstValue, + Is.`is`(CredentialsManagerException.SESSION_EXPIRED) + ) + // No biometric prompt should be raised for a dead session. + verify(localAuthenticationManager, never()).authenticate() + // The breached session must be cleared. The refresh-token grant must never be used past the + // ceiling, asserted by verifyNoMoreInteractions(client) at the top of this test. + verify(storage).remove("com.auth0.session_expiry") + verify(storage).remove("com.auth0.credentials") + } + + @Test + public fun shouldFailWithSessionExpiredWhenStoredCeilingFallsWithinLeeway() { + Mockito.`when`(localAuthenticationManager.authenticate()).then { + localAuthenticationManager.resultCallback.onSuccess(true) + } + verifyNoMoreInteractions(client) + val nowSeconds = CredentialsMock.CURRENT_TIME_MS / 1000 + // 10s ahead, but inside the 30s negative leeway -> treated as expired. + Mockito.`when`(storage.retrieveLong("com.auth0.session_expiry")).thenReturn(nowSeconds + 10) + + manager.getCredentials(callback) + + verify(callback).onFailure(exceptionCaptor.capture()) + MatcherAssert.assertThat( + exceptionCaptor.firstValue, + Is.`is`(CredentialsManagerException.SESSION_EXPIRED) + ) + // No refresh past the ceiling, asserted by verifyNoMoreInteractions(client) at the top. + } + + @Test + public fun shouldGetCredentialsWhenStoredSessionCeilingNotReached() { + Mockito.`when`(localAuthenticationManager.authenticate()).then { + localAuthenticationManager.resultCallback.onSuccess(true) + } + verifyNoMoreInteractions(client) + val nowSeconds = CredentialsMock.CURRENT_TIME_MS / 1000 + Mockito.`when`(storage.retrieveLong("com.auth0.session_expiry")) + .thenReturn(nowSeconds + 100_000) + val expiresAt = Date(CredentialsMock.CURRENT_TIME_MS + ONE_HOUR_SECONDS * 1000) + insertTestCredentials(true, true, true, expiresAt, "scope") + + manager.getCredentials(callback) + + verify(callback).onSuccess(credentialsCaptor.capture()) + MatcherAssert.assertThat(credentialsCaptor.firstValue, Is.`is`(Matchers.notNullValue())) + // No refresh needed (token not expired), asserted by verifyNoMoreInteractions(client) at the top. + } + + @Test + public fun shouldNotEnforceSessionExpiryWhenNoStoredCeiling() { + Mockito.`when`(localAuthenticationManager.authenticate()).then { + localAuthenticationManager.resultCallback.onSuccess(true) + } + verifyNoMoreInteractions(client) + // No stored ceiling -> existing behavior, no regression. + Mockito.`when`(storage.retrieveLong("com.auth0.session_expiry")).thenReturn(null) + val expiresAt = Date(CredentialsMock.CURRENT_TIME_MS + ONE_HOUR_SECONDS * 1000) + insertTestCredentials(true, true, true, expiresAt, "scope") + + manager.getCredentials(callback) + + verify(callback).onSuccess(credentialsCaptor.capture()) + MatcherAssert.assertThat(credentialsCaptor.firstValue, Is.`is`(Matchers.notNullValue())) + } + + @Test + public fun shouldThrowWhenSavingCredentialsAlreadyPastSessionCeiling() { + val credentials = CredentialsMock.create( + "idToken", "accessToken", "type", "refreshToken", + Date(CredentialsMock.ONE_HOUR_AHEAD_MS), "scope" + ) + val jwtMock = mock() + // session_expiry (1000) is at/below iat (2000) -> already expired at creation. + Mockito.`when`(jwtMock.sessionExpiry).thenReturn(1000L) + Mockito.`when`(jwtMock.issuedAt).thenReturn(Date(2000L * 1000)) + Mockito.`when`(jwtDecoder.decode("idToken")).thenReturn(jwtMock) + + val exception = assertThrows(CredentialsManagerException::class.java) { + manager.saveCredentials(credentials) + } + MatcherAssert.assertThat(exception, Is.`is`(CredentialsManagerException.SESSION_EXPIRED)) + verify(storage, never()).store(eq("com.auth0.credentials"), anyString()) + } + + @Test + public fun shouldPersistSessionExpiryWhenSavingCredentials() { + val expirationTime = CredentialsMock.ONE_HOUR_AHEAD_MS + val credentials = CredentialsMock.create( + "idToken", "accessToken", "type", "refreshToken", Date(expirationTime), "scope" + ) + val sessionExpiry = (expirationTime / 1000) + 100_000 + val json = gson.toJson(credentials) + val jwtMock = mock() + Mockito.`when`(jwtMock.expiresAt).thenReturn(Date(expirationTime)) + Mockito.`when`(jwtMock.sessionExpiry).thenReturn(sessionExpiry) + Mockito.`when`(jwtMock.issuedAt).thenReturn(Date(CredentialsMock.CURRENT_TIME_MS)) + Mockito.`when`(jwtDecoder.decode("idToken")).thenReturn(jwtMock) + Mockito.`when`(crypto.encrypt(json.toByteArray())).thenReturn(json.toByteArray()) + + manager.saveCredentials(credentials) + + verify(storage).store("com.auth0.session_expiry", sessionExpiry) + } + private fun insertTestCredentials( hasIdToken: Boolean, hasAccessToken: Boolean, @@ -3934,6 +4057,9 @@ public class SecureCredentialsManagerTest { private fun prepareJwtDecoderMock(expiresAt: Date?) { val jwtMock = mock() Mockito.`when`(jwtMock.expiresAt).thenReturn(expiresAt) + // Default to a token without the IPSIE session_expiry claim. Mockito returns 0 (not null) for + // an unstubbed Long?-returning property, which would otherwise trigger a spurious persist. + Mockito.`when`(jwtMock.sessionExpiry).thenReturn(null) Mockito.`when`(jwtDecoder.decode("idToken")).thenReturn(jwtMock) } diff --git a/auth0/src/test/java/com/auth0/android/request/internal/JwtTest.kt b/auth0/src/test/java/com/auth0/android/request/internal/JwtTest.kt index 17e768a5e..cdff660fb 100644 --- a/auth0/src/test/java/com/auth0/android/request/internal/JwtTest.kt +++ b/auth0/src/test/java/com/auth0/android/request/internal/JwtTest.kt @@ -1,5 +1,6 @@ package com.auth0.android.request.internal +import android.util.Base64 import androidx.test.espresso.matcher.ViewMatchers.assertThat import com.google.gson.stream.MalformedJsonException import org.hamcrest.Matchers.* @@ -203,4 +204,53 @@ public class JwtTest { assertThat(jwt.issuedAt, `is`(nullValue())) } + + // IPSIE session_expiry claim + + @Test + public fun shouldGetSessionExpiryAsLongSeconds() { + val jwt = Jwt(jwtWithPayload("""{"session_expiry":1700000000}""")) + assertThat(jwt, `is`(notNullValue())) + assertThat(jwt.sessionExpiry, `is`(1700000000L)) + } + + @Test + public fun shouldGetNullSessionExpiryIfMissing() { + val jwt = Jwt("eyJhbGciOiJIUzI1NiJ9.e30.something") + assertThat(jwt, `is`(notNullValue())) + + assertThat(jwt.sessionExpiry, `is`(nullValue())) + } + + @Test + public fun shouldGetNullSessionExpiryIfNonNumeric() { + val jwt = Jwt(jwtWithPayload("""{"session_expiry":"not-a-number"}""")) + assertThat(jwt, `is`(notNullValue())) + + assertThat(jwt.sessionExpiry, `is`(nullValue())) + } + + @Test + public fun shouldTruncateFractionalSessionExpiryToLong() { + val jwt = Jwt(jwtWithPayload("""{"session_expiry":1700000000.75}""")) + assertThat(jwt, `is`(notNullValue())) + + assertThat(jwt.sessionExpiry, `is`(1700000000L)) + } + + /** + * Builds a JWT with a fixed `alg=HS256` header and a dummy signature, encoding the given JSON + * payload so that [Jwt] can decode it. The signature is never verified by [Jwt]. + */ + private fun jwtWithPayload(jsonPayload: String): String { + val header = encode("""{"alg":"HS256","typ":"JWT"}""") + val payload = encode(jsonPayload) + return "$header.$payload.signature" + } + + private fun encode(json: String): String = + Base64.encodeToString( + json.toByteArray(Charsets.UTF_8), + Base64.URL_SAFE or Base64.NO_WRAP or Base64.NO_PADDING + ) } \ No newline at end of file diff --git a/auth0/src/test/java/com/auth0/android/result/CredentialsTest.kt b/auth0/src/test/java/com/auth0/android/result/CredentialsTest.kt index ac38dc36b..b06f1f194 100644 --- a/auth0/src/test/java/com/auth0/android/result/CredentialsTest.kt +++ b/auth0/src/test/java/com/auth0/android/result/CredentialsTest.kt @@ -1,5 +1,6 @@ package com.auth0.android.result +import android.util.Base64 import com.auth0.android.request.internal.GsonProvider.gson import org.hamcrest.MatcherAssert import org.hamcrest.Matchers @@ -81,4 +82,41 @@ public class CredentialsTest { Matchers.`is`("Credentials(idToken='xxxxx', accessToken='xxxxx', type='type', refreshToken='xxxxx', expiresAt='$date', scope='scope')") ) } + + @Test + public fun shouldGetSessionExpiresAtFromIdToken() { + val credentials = Credentials( + jwtWithPayload("""{"session_expiry":1700000000}"""), + "accessToken", "type", "refreshToken", Date(), "scope" + ) + MatcherAssert.assertThat(credentials.sessionExpiresAt, Matchers.`is`(1700000000L)) + } + + @Test + public fun shouldGetNullSessionExpiresAtWhenClaimMissing() { + val credentials = Credentials( + jwtWithPayload("""{"sub":"auth0|123456"}"""), + "accessToken", "type", "refreshToken", Date(), "scope" + ) + MatcherAssert.assertThat(credentials.sessionExpiresAt, Matchers.`is`(Matchers.nullValue())) + } + + @Test + public fun shouldGetNullSessionExpiresAtWhenIdTokenIsNotAValidJwt() { + val credentials = + CredentialsMock.create("not-a-jwt", "accessToken", "type", "refreshToken", Date(), "scope") + MatcherAssert.assertThat(credentials.sessionExpiresAt, Matchers.`is`(Matchers.nullValue())) + } + + private fun jwtWithPayload(jsonPayload: String): String { + val header = encode("""{"alg":"HS256","typ":"JWT"}""") + val payload = encode(jsonPayload) + return "$header.$payload.signature" + } + + private fun encode(json: String): String = + Base64.encodeToString( + json.toByteArray(Charsets.UTF_8), + Base64.URL_SAFE or Base64.NO_WRAP or Base64.NO_PADDING + ) } \ No newline at end of file From 3f716ea1000db8bb02b0f28e5201d3c58df10fa1 Mon Sep 17 00:00:00 2001 From: Sanchit Mehta Date: Fri, 19 Jun 2026 13:00:48 +0530 Subject: [PATCH 43/60] docs: mention auth0-android-major-migration skill in V4 migration guide --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index b8e09bfbd..e42e0e6e3 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,8 @@ We'd love for you to try it out and share your feedback! Please [open an issue]( 📚 [Migration Guide](https://github.com/auth0/Auth0.Android/blob/v4_development/V4_MIGRATION_GUIDE.md) 📦 [v4 Changelog](https://github.com/auth0/Auth0.Android/blob/v4_development/CHANGELOG.md) +**Skill for Coding Agents:** If you use coding agents such as Claude Code or Cursor, add the Auth0.Android migration skill to automate the upgrade: `npx skills add auth0/agent-skills --skill auth0-android-major-migration` + ## Documentation - [Quickstart](https://auth0.com/docs/quickstart/native/android/interactive) - [Sample App](https://github.com/auth0-samples/auth0-android-sample/tree/master/00-Login-Kt) From b6e03ba9a53aacfcbfd1d8f96571fb31a1abdc2b Mon Sep 17 00:00:00 2001 From: Sanchit Mehta Date: Fri, 19 Jun 2026 14:19:02 +0530 Subject: [PATCH 44/60] docs: improve coding agent skill mention in README Rewords the blurb to a question-style hook, links Claude Code and Cursor, and wraps the install command in a bash code block for clarity. --- README.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index e42e0e6e3..e1bd8d20d 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,11 @@ We'd love for you to try it out and share your feedback! Please [open an issue]( 📚 [Migration Guide](https://github.com/auth0/Auth0.Android/blob/v4_development/V4_MIGRATION_GUIDE.md) 📦 [v4 Changelog](https://github.com/auth0/Auth0.Android/blob/v4_development/CHANGELOG.md) -**Skill for Coding Agents:** If you use coding agents such as Claude Code or Cursor, add the Auth0.Android migration skill to automate the upgrade: `npx skills add auth0/agent-skills --skill auth0-android-major-migration` +**Migrating with a coding agent?** If you use [Claude Code](https://claude.ai/code) or [Cursor](https://cursor.com), add the Auth0.Android migration skill to automate the v3 → v4 upgrade: + +```bash +npx skills add auth0/agent-skills --skill auth0-android-major-migration +``` ## Documentation - [Quickstart](https://auth0.com/docs/quickstart/native/android/interactive) From 36a60c8130fa805ac9f7ab653ba973049b71dc0d Mon Sep 17 00:00:00 2001 From: utkrishtS Date: Fri, 19 Jun 2026 15:37:55 +0530 Subject: [PATCH 45/60] fix: enforce session_expiry on SSO/API refresh paths and address review feedback --- .../storage/CredentialsManager.kt | 18 ++++++ .../storage/SecureCredentialsManager.kt | 29 ++++++++- .../storage/CredentialsManagerTest.kt | 40 ++++++++++++ .../storage/SecureCredentialsManagerTest.kt | 64 ++++++++++++++++--- 4 files changed, 142 insertions(+), 9 deletions(-) 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 29499c27c..23217fd13 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 @@ -63,7 +63,11 @@ public class CredentialsManager @VisibleForTesting(otherwise = VisibleForTesting * Stores the given credentials in the storage. Must have an access_token or id_token and a expires_in value. * * @param credentials the credentials to save in the storage. + * @throws CredentialsManagerException with code `SESSION_EXPIRED` if the credentials carry an + * IPSIE `session_expiry` claim that is already past its ceiling at creation time, or with code + * `INVALID_CREDENTIALS` if neither an access_token nor an id_token is present. */ + @Throws(CredentialsManagerException::class) override fun saveCredentials(credentials: Credentials) { if (TextUtils.isEmpty(credentials.accessToken) && TextUtils.isEmpty(credentials.idToken)) { throw CredentialsManagerException.INVALID_CREDENTIALS @@ -134,6 +138,13 @@ public class CredentialsManager @VisibleForTesting(otherwise = VisibleForTesting ) { serialExecutor.execute { runCatchingOnExecutor(callback) { + // IPSIE session_expiry: enforce the upstream-IdP session ceiling before exchanging the + // refresh token, so the SSO exchange is never used to outlive the session. + if (isSessionExpired(storage.retrieveString(KEY_ID_TOKEN))) { + clearCredentials() + callback.onFailure(CredentialsManagerException.SESSION_EXPIRED) + return@execute + } val refreshToken = storage.retrieveString(KEY_REFRESH_TOKEN) if (refreshToken.isNullOrEmpty()) { callback.onFailure(CredentialsManagerException.NO_REFRESH_TOKEN) @@ -593,6 +604,13 @@ public class CredentialsManager @VisibleForTesting(otherwise = VisibleForTesting ) { serialExecutor.execute { runCatchingOnExecutor(callback) { + // IPSIE session_expiry: enforce the upstream-IdP session ceiling before serving cached + // API credentials or exchanging the refresh token, so the session is never extended past it. + if (isSessionExpired(storage.retrieveString(KEY_ID_TOKEN))) { + clearCredentials() + callback.onFailure(CredentialsManagerException.SESSION_EXPIRED) + return@execute + } val key = getAPICredentialsKey(audience, scope) val apiCredentialsJson = storage.retrieveString(key) var apiCredentialType: String? = null 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 ac8119648..0851f9e74 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 @@ -169,7 +169,10 @@ public class SecureCredentialsManager @VisibleForTesting(otherwise = VisibleForT * Saves the given credentials in the Storage. * * @param credentials the credentials to save. - * @throws CredentialsManagerException if the credentials couldn't be encrypted. Some devices are not compatible at all with the cryptographic + * @throws CredentialsManagerException with code `SESSION_EXPIRED` if the credentials carry an + * IPSIE `session_expiry` claim that is already past its ceiling at creation time, with code + * `INVALID_CREDENTIALS` if neither an access_token nor an id_token is present, or if the + * credentials couldn't be encrypted. Some devices are not compatible at all with the cryptographic * implementation and will have [CredentialsManagerException.isDeviceIncompatible] return true. */ @Throws(CredentialsManagerException::class) @@ -283,6 +286,13 @@ public class SecureCredentialsManager @VisibleForTesting(otherwise = VisibleForT callback.onFailure(exception) return@execute } + // IPSIE session_expiry: enforce the upstream-IdP session ceiling before exchanging the + // refresh token, so the SSO exchange is never used to outlive the session. + if (isSessionExpired(existingCredentials.idToken)) { + clearCredentials() + callback.onFailure(CredentialsManagerException.SESSION_EXPIRED) + return@execute + } if (existingCredentials.refreshToken.isNullOrEmpty()) { callback.onFailure(CredentialsManagerException.NO_REFRESH_TOKEN) return@execute @@ -715,6 +725,14 @@ public class SecureCredentialsManager @VisibleForTesting(otherwise = VisibleForT headers: Map, callback: Callback ) { + // IPSIE session_expiry: short-circuit before any biometric prompt or refresh. The ceiling is + // read from the value persisted at login (KEY_SESSION_EXPIRY); past it we clear and surface the + // dedicated error rather than prompting biometrics for a session that can no longer be served. + if (isSessionExpired(null)) { + clearCredentials() + callback.onFailure(CredentialsManagerException.SESSION_EXPIRED) + return + } if (fragmentActivity != null && localAuthenticationOptions != null && localAuthenticationManagerFactory != null) { @@ -977,6 +995,15 @@ public class SecureCredentialsManager @VisibleForTesting(otherwise = VisibleForT ) { serialExecutor.execute { runCatchingOnExecutor(callback) { + // IPSIE session_expiry: enforce the upstream-IdP session ceiling before serving cached + // API credentials or exchanging the refresh token. The ceiling is read from the value + // persisted at login (KEY_SESSION_EXPIRY) so it holds even though the credentials blob + // is encrypted; past it we clear and surface the dedicated error. + if (isSessionExpired(null)) { + clearCredentials() + callback.onFailure(CredentialsManagerException.SESSION_EXPIRED) + return@execute + } val encryptedEncodedJson = storage.retrieveString(getAPICredentialsKey(audience, scope)) diff --git a/auth0/src/test/java/com/auth0/android/authentication/storage/CredentialsManagerTest.kt b/auth0/src/test/java/com/auth0/android/authentication/storage/CredentialsManagerTest.kt index 2a81b7373..23ebff264 100644 --- a/auth0/src/test/java/com/auth0/android/authentication/storage/CredentialsManagerTest.kt +++ b/auth0/src/test/java/com/auth0/android/authentication/storage/CredentialsManagerTest.kt @@ -2695,6 +2695,46 @@ public class CredentialsManagerTest { verify(storage).store("com.auth0.session_expiry", sessionExpiry) } + @Test + public fun shouldFailGetSsoCredentialsWithSessionExpiredWhenSessionCeilingReached() { + val nowSeconds = CredentialsMock.CURRENT_TIME_MS / 1000 + Mockito.`when`(storage.retrieveString("com.auth0.id_token")).thenReturn("idToken") + Mockito.`when`(storage.retrieveString("com.auth0.refresh_token")).thenReturn("refreshToken") + prepareJwtDecoderMockWithSessionExpiry(nowSeconds - 100) + + manager.getSsoCredentials(ssoCallback) + + verify(ssoCallback).onFailure(exceptionCaptor.capture()) + MatcherAssert.assertThat( + exceptionCaptor.firstValue, + Is.`is`(CredentialsManagerException.SESSION_EXPIRED) + ) + // The refresh-token grant must never be exchanged for SSO credentials past the ceiling. + verifyZeroInteractions(client) + verify(storage).remove("com.auth0.session_expiry") + verify(storage).remove("com.auth0.id_token") + } + + @Test + public fun shouldFailGetApiCredentialsWithSessionExpiredWhenSessionCeilingReached() { + val nowSeconds = CredentialsMock.CURRENT_TIME_MS / 1000 + Mockito.`when`(storage.retrieveString("com.auth0.id_token")).thenReturn("idToken") + Mockito.`when`(storage.retrieveString("com.auth0.refresh_token")).thenReturn("refreshToken") + prepareJwtDecoderMockWithSessionExpiry(nowSeconds - 100) + + manager.getApiCredentials("audience", "scope", callback = apiCredentialsCallback) + + verify(apiCredentialsCallback).onFailure(exceptionCaptor.capture()) + MatcherAssert.assertThat( + exceptionCaptor.firstValue, + Is.`is`(CredentialsManagerException.SESSION_EXPIRED) + ) + // The refresh-token grant must never be exchanged for API credentials past the ceiling. + verifyZeroInteractions(client) + verify(storage).remove("com.auth0.session_expiry") + verify(storage).remove("com.auth0.id_token") + } + private fun prepareJwtDecoderMockWithSessionExpiry(sessionExpiry: Long?) { val jwtMock = mock() Mockito.`when`(jwtMock.sessionExpiry).thenReturn(sessionExpiry) diff --git a/auth0/src/test/java/com/auth0/android/authentication/storage/SecureCredentialsManagerTest.kt b/auth0/src/test/java/com/auth0/android/authentication/storage/SecureCredentialsManagerTest.kt index a3edb5d9d..fde66fbc5 100644 --- a/auth0/src/test/java/com/auth0/android/authentication/storage/SecureCredentialsManagerTest.kt +++ b/auth0/src/test/java/com/auth0/android/authentication/storage/SecureCredentialsManagerTest.kt @@ -3579,7 +3579,6 @@ public class SecureCredentialsManagerTest { Mockito.`when`(localAuthenticationManager.authenticate()).then { localAuthenticationManager.resultCallback.onSuccess(true) } - verifyNoMoreInteractions(client) val nowSeconds = CredentialsMock.CURRENT_TIME_MS / 1000 // Stored ceiling already in the past. Mockito.`when`(storage.retrieveLong("com.auth0.session_expiry")).thenReturn(nowSeconds - 100) @@ -3593,10 +3592,11 @@ public class SecureCredentialsManagerTest { ) // No biometric prompt should be raised for a dead session. verify(localAuthenticationManager, never()).authenticate() - // The breached session must be cleared. The refresh-token grant must never be used past the - // ceiling, asserted by verifyNoMoreInteractions(client) at the top of this test. + // The breached session must be cleared. verify(storage).remove("com.auth0.session_expiry") verify(storage).remove("com.auth0.credentials") + // The refresh-token grant must never be used past the ceiling. + verifyNoMoreInteractions(client) } @Test @@ -3604,7 +3604,6 @@ public class SecureCredentialsManagerTest { Mockito.`when`(localAuthenticationManager.authenticate()).then { localAuthenticationManager.resultCallback.onSuccess(true) } - verifyNoMoreInteractions(client) val nowSeconds = CredentialsMock.CURRENT_TIME_MS / 1000 // 10s ahead, but inside the 30s negative leeway -> treated as expired. Mockito.`when`(storage.retrieveLong("com.auth0.session_expiry")).thenReturn(nowSeconds + 10) @@ -3616,7 +3615,8 @@ public class SecureCredentialsManagerTest { exceptionCaptor.firstValue, Is.`is`(CredentialsManagerException.SESSION_EXPIRED) ) - // No refresh past the ceiling, asserted by verifyNoMoreInteractions(client) at the top. + // No refresh past the ceiling. + verifyNoMoreInteractions(client) } @Test @@ -3624,7 +3624,6 @@ public class SecureCredentialsManagerTest { Mockito.`when`(localAuthenticationManager.authenticate()).then { localAuthenticationManager.resultCallback.onSuccess(true) } - verifyNoMoreInteractions(client) val nowSeconds = CredentialsMock.CURRENT_TIME_MS / 1000 Mockito.`when`(storage.retrieveLong("com.auth0.session_expiry")) .thenReturn(nowSeconds + 100_000) @@ -3635,7 +3634,8 @@ public class SecureCredentialsManagerTest { verify(callback).onSuccess(credentialsCaptor.capture()) MatcherAssert.assertThat(credentialsCaptor.firstValue, Is.`is`(Matchers.notNullValue())) - // No refresh needed (token not expired), asserted by verifyNoMoreInteractions(client) at the top. + // No refresh needed (token not expired). + verifyNoMoreInteractions(client) } @Test @@ -3643,7 +3643,6 @@ public class SecureCredentialsManagerTest { Mockito.`when`(localAuthenticationManager.authenticate()).then { localAuthenticationManager.resultCallback.onSuccess(true) } - verifyNoMoreInteractions(client) // No stored ceiling -> existing behavior, no regression. Mockito.`when`(storage.retrieveLong("com.auth0.session_expiry")).thenReturn(null) val expiresAt = Date(CredentialsMock.CURRENT_TIME_MS + ONE_HOUR_SECONDS * 1000) @@ -3653,6 +3652,7 @@ public class SecureCredentialsManagerTest { verify(callback).onSuccess(credentialsCaptor.capture()) MatcherAssert.assertThat(credentialsCaptor.firstValue, Is.`is`(Matchers.notNullValue())) + verifyNoMoreInteractions(client) } @Test @@ -3694,6 +3694,54 @@ public class SecureCredentialsManagerTest { verify(storage).store("com.auth0.session_expiry", sessionExpiry) } + @Test + public fun shouldFailGetSsoCredentialsWithSessionExpiredWhenSessionCeilingReached() { + val nowSeconds = CredentialsMock.CURRENT_TIME_MS / 1000 + insertTestCredentials( + hasIdToken = true, + hasAccessToken = true, + hasRefreshToken = true, + willExpireAt = Date(CredentialsMock.ONE_HOUR_AHEAD_MS), + scope = "scope" + ) + // The decrypted ID token carries a session_expiry ceiling already in the past. + val jwtMock = mock() + Mockito.`when`(jwtMock.sessionExpiry).thenReturn(nowSeconds - 100) + Mockito.`when`(jwtDecoder.decode("idToken")).thenReturn(jwtMock) + + manager.getSsoCredentials(ssoCallback) + + verify(ssoCallback).onFailure(exceptionCaptor.capture()) + MatcherAssert.assertThat( + exceptionCaptor.firstValue, + Is.`is`(CredentialsManagerException.SESSION_EXPIRED) + ) + // The refresh-token grant must never be exchanged for SSO credentials past the ceiling. + verifyNoMoreInteractions(client) + verify(storage).remove("com.auth0.session_expiry") + verify(storage).remove("com.auth0.credentials") + } + + @Test + public fun shouldFailGetApiCredentialsWithSessionExpiredWhenStoredCeilingReached() { + val nowSeconds = CredentialsMock.CURRENT_TIME_MS / 1000 + // Stored ceiling already in the past; the encrypted blob need not be read. + Mockito.`when`(storage.retrieveLong("com.auth0.session_expiry")).thenReturn(nowSeconds - 100) + + manager.getApiCredentials("audience", "scope", callback = apiCredentialsCallback) + + verify(apiCredentialsCallback).onFailure(exceptionCaptor.capture()) + MatcherAssert.assertThat( + exceptionCaptor.firstValue, + Is.`is`(CredentialsManagerException.SESSION_EXPIRED) + ) + // No biometric prompt and no refresh past the ceiling. + verify(localAuthenticationManager, never()).authenticate() + verifyNoMoreInteractions(client) + verify(storage).remove("com.auth0.session_expiry") + verify(storage).remove("com.auth0.credentials") + } + private fun insertTestCredentials( hasIdToken: Boolean, hasAccessToken: Boolean, From 14de74ba39b211b408f9fee9805ce1cc9163ea91 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 19 Jun 2026 16:13:27 +0530 Subject: [PATCH 46/60] chore(deps): bump actions/checkout from 6.0.3 to 7.0.0 (#986) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Prince Mathew <17837162+pmathew92@users.noreply.github.com> --- .github/workflows/codeql.yml | 2 +- .github/workflows/java-release.yml | 2 +- .github/workflows/release.yml | 2 +- .github/workflows/test.yml | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index b3d19ecff..49f23397d 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -42,7 +42,7 @@ jobs: java-version: '11' - name: Checkout - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Initialize CodeQL uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2 diff --git a/.github/workflows/java-release.yml b/.github/workflows/java-release.yml index 5221cf5f0..c256df64a 100644 --- a/.github/workflows/java-release.yml +++ b/.github/workflows/java-release.yml @@ -30,7 +30,7 @@ jobs: steps: # Checkout the code - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: fetch-depth: 0 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 423125458..a0a6ed7b7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -19,7 +19,7 @@ jobs: if: github.event_name == 'workflow_dispatch' || (github.event_name == 'pull_request' && github.event.pull_request.merged && startsWith(github.event.pull_request.head.ref, 'release/')) runs-on: ubuntu-latest steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: fetch-depth: 0 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 5c642ebac..296a4fcac 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -23,7 +23,7 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - uses: ./.github/actions/setup From bb44ffc9090c07ad07f47a6280606fd5573340d9 Mon Sep 17 00:00:00 2001 From: utkrishtS Date: Fri, 19 Jun 2026 20:01:21 +0530 Subject: [PATCH 47/60] fix: pin session_expiry at login and ignore it on refresh grants --- .../storage/BaseCredentialsManager.kt | 18 ++++++++++---- .../storage/CredentialsManagerTest.kt | 22 +++++++++++++++++ .../storage/SecureCredentialsManagerTest.kt | 24 +++++++++++++++++++ 3 files changed, 59 insertions(+), 5 deletions(-) diff --git a/auth0/src/main/java/com/auth0/android/authentication/storage/BaseCredentialsManager.kt b/auth0/src/main/java/com/auth0/android/authentication/storage/BaseCredentialsManager.kt index 9be239d25..669a82084 100644 --- a/auth0/src/main/java/com/auth0/android/authentication/storage/BaseCredentialsManager.kt +++ b/auth0/src/main/java/com/auth0/android/authentication/storage/BaseCredentialsManager.kt @@ -349,14 +349,22 @@ public abstract class BaseCredentialsManager internal constructor( } /** - * Persists the `session_expiry` ceiling read from the given ID token, if present. + * Pins the `session_expiry` ceiling from the initial login and preserves it across refreshes. * - * To preserve the ceiling across refreshes (the refresh grant does not re-emit `session_expiry`), - * the stored value is only ever written, never cleared, when a fresh ID token omits the claim. - * Call from `saveCredentials`. + * The ceiling is read once and stamped onto the session at login: it is stored only when no value + * is already persisted. A `session_expiry` re-emitted on a later (refresh) grant is deliberately + * ignored, so the bound stays pinned to the initial-login value and a refresh can never extend the + * session past it. [clearCredentials] removes the stored value on logout, so the next login re-pins + * a fresh ceiling. Call from `saveCredentials`. */ protected fun persistSessionExpiry(idToken: String?) { - sessionExpiryFromIdToken(idToken)?.let { storage.store(KEY_SESSION_EXPIRY, it) } + val incoming = sessionExpiryFromIdToken(idToken) ?: return + // A positive value is already pinned from the initial login -> keep it; ignore the claim + // re-emitted on this (refresh) grant. A null/non-positive stored value means nothing is pinned + // yet (mirrors the unset/migration guard in [isSessionExpired]), so stamp the ceiling now. + val pinned = storage.retrieveLong(KEY_SESSION_EXPIRY) + if (pinned != null && pinned > 0) return + storage.store(KEY_SESSION_EXPIRY, incoming) } /** diff --git a/auth0/src/test/java/com/auth0/android/authentication/storage/CredentialsManagerTest.kt b/auth0/src/test/java/com/auth0/android/authentication/storage/CredentialsManagerTest.kt index 23ebff264..3023855b3 100644 --- a/auth0/src/test/java/com/auth0/android/authentication/storage/CredentialsManagerTest.kt +++ b/auth0/src/test/java/com/auth0/android/authentication/storage/CredentialsManagerTest.kt @@ -2735,6 +2735,28 @@ public class CredentialsManagerTest { verify(storage).remove("com.auth0.id_token") } + @Test + public fun shouldNotOverwriteStoredSessionExpiryWhenSavingRefreshedCredentials() { + val credentials: Credentials = CredentialsMock.create( + "idToken", "accessToken", "type", "refreshToken", + Date(CredentialsMock.ONE_HOUR_AHEAD_MS), "scope" + ) + // A ceiling is already pinned from the initial login. + val pinnedCeiling = (CredentialsMock.ONE_HOUR_AHEAD_MS / 1000) + 100_000 + Mockito.`when`(storage.retrieveLong("com.auth0.session_expiry")).thenReturn(pinnedCeiling) + // The refreshed ID token re-emits a later session_expiry that must be ignored. + val jwtMock = mock() + Mockito.`when`(jwtMock.sessionExpiry).thenReturn(pinnedCeiling + 100_000) + Mockito.`when`(jwtMock.issuedAt).thenReturn(Date(CredentialsMock.CURRENT_TIME_MS)) + Mockito.`when`(jwtMock.expiresAt).thenReturn(Date(CredentialsMock.ONE_HOUR_AHEAD_MS)) + Mockito.`when`(jwtDecoder.decode("idToken")).thenReturn(jwtMock) + + manager.saveCredentials(credentials) + + // The pinned ceiling must not be moved by a refresh-grant claim. + verify(storage, never()).store(eq("com.auth0.session_expiry"), ArgumentMatchers.anyLong()) + } + private fun prepareJwtDecoderMockWithSessionExpiry(sessionExpiry: Long?) { val jwtMock = mock() Mockito.`when`(jwtMock.sessionExpiry).thenReturn(sessionExpiry) diff --git a/auth0/src/test/java/com/auth0/android/authentication/storage/SecureCredentialsManagerTest.kt b/auth0/src/test/java/com/auth0/android/authentication/storage/SecureCredentialsManagerTest.kt index fde66fbc5..61a5035bf 100644 --- a/auth0/src/test/java/com/auth0/android/authentication/storage/SecureCredentialsManagerTest.kt +++ b/auth0/src/test/java/com/auth0/android/authentication/storage/SecureCredentialsManagerTest.kt @@ -3694,6 +3694,30 @@ public class SecureCredentialsManagerTest { verify(storage).store("com.auth0.session_expiry", sessionExpiry) } + @Test + public fun shouldNotOverwriteStoredSessionExpiryWhenSavingRefreshedCredentials() { + val expirationTime = CredentialsMock.ONE_HOUR_AHEAD_MS + val credentials = CredentialsMock.create( + "idToken", "accessToken", "type", "refreshToken", Date(expirationTime), "scope" + ) + // A ceiling is already pinned from the initial login. + val pinnedCeiling = (expirationTime / 1000) + 100_000 + Mockito.`when`(storage.retrieveLong("com.auth0.session_expiry")).thenReturn(pinnedCeiling) + val json = gson.toJson(credentials) + // The refreshed ID token re-emits a later session_expiry that must be ignored. + val jwtMock = mock() + Mockito.`when`(jwtMock.expiresAt).thenReturn(Date(expirationTime)) + Mockito.`when`(jwtMock.sessionExpiry).thenReturn(pinnedCeiling + 100_000) + Mockito.`when`(jwtMock.issuedAt).thenReturn(Date(CredentialsMock.CURRENT_TIME_MS)) + Mockito.`when`(jwtDecoder.decode("idToken")).thenReturn(jwtMock) + Mockito.`when`(crypto.encrypt(json.toByteArray())).thenReturn(json.toByteArray()) + + manager.saveCredentials(credentials) + + // The pinned ceiling must not be moved by a refresh-grant claim. + verify(storage, never()).store(eq("com.auth0.session_expiry"), anyLong()) + } + @Test public fun shouldFailGetSsoCredentialsWithSessionExpiredWhenSessionCeilingReached() { val nowSeconds = CredentialsMock.CURRENT_TIME_MS / 1000 From dd74bac3d6dfdcebd0df014c4d52bd860a522bb4 Mon Sep 17 00:00:00 2001 From: utkrishtS Date: Mon, 22 Jun 2026 17:07:14 +0530 Subject: [PATCH 48/60] fix: address review feedback on session_expiry enforcement --- .../storage/BaseCredentialsManager.kt | 40 ++++++++++++++----- .../storage/CredentialsManager.kt | 18 +++++---- .../storage/SecureCredentialsManager.kt | 4 +- .../com/auth0/android/request/internal/Jwt.kt | 19 +++++++-- .../com/auth0/android/result/Credentials.kt | 17 ++++++-- .../storage/CredentialsManagerTest.kt | 33 +++++++++++++++ .../storage/SecureCredentialsManagerTest.kt | 4 ++ .../auth0/android/request/internal/JwtTest.kt | 10 +++++ .../auth0/android/result/CredentialsTest.kt | 22 ++++++++++ 9 files changed, 140 insertions(+), 27 deletions(-) diff --git a/auth0/src/main/java/com/auth0/android/authentication/storage/BaseCredentialsManager.kt b/auth0/src/main/java/com/auth0/android/authentication/storage/BaseCredentialsManager.kt index 669a82084..a4a8184d9 100644 --- a/auth0/src/main/java/com/auth0/android/authentication/storage/BaseCredentialsManager.kt +++ b/auth0/src/main/java/com/auth0/android/authentication/storage/BaseCredentialsManager.kt @@ -327,17 +327,19 @@ public abstract class BaseCredentialsManager internal constructor( /** * Checks whether the upstream-IdP session ceiling (`session_expiry`) has been reached. * - * The ceiling is resolved in order: (1) the live claim in [idToken]; (2) the value persisted at - * login under [KEY_SESSION_EXPIRY] (so a refresh whose ID token omits the claim does not silently - * drop the ceiling); (3) if neither is present there is no ceiling and the session is NOT expired - * — a missing value must fall through to existing behavior, never be treated as already-expired. + * The ceiling is resolved in order: (1) the value pinned at login under [KEY_SESSION_EXPIRY]; + * (2) the live claim in [idToken], as a fallback only when nothing is pinned yet (migration of a + * session saved before this control existed); (3) if neither is present there is no ceiling and + * the session is NOT expired — a missing value must fall through to existing behavior, never be + * treated as already-expired. The pinned value is read first because the ceiling is fixed at the + * initial login: a refresh whose ID token re-emits a *later* `session_expiry` must never raise it. * * A small negative clock-skew leeway is applied so the session is treated as expired slightly * before the wall-clock ceiling, never after. */ protected fun isSessionExpired(idToken: String?): Boolean { - val sessionExpiry = sessionExpiryFromIdToken(idToken) - ?: storage.retrieveLong(KEY_SESSION_EXPIRY) + val sessionExpiry = storage.retrieveLong(KEY_SESSION_EXPIRY) + ?: sessionExpiryFromIdToken(idToken) ?: return false // A non-positive ceiling is not a valid Unix timestamp; treat it as "no ceiling" rather than // already-expired (mirrors the guard in [willExpire] for unset/migration values). @@ -348,6 +350,20 @@ public abstract class BaseCredentialsManager internal constructor( return nowSeconds + SESSION_EXPIRY_LEEWAY_SECONDS >= sessionExpiry } + /** + * Stamps the pinned `session_expiry` ceiling (the value persisted at login under + * [KEY_SESSION_EXPIRY]) onto [credentials] so its public `sessionExpiresAt` reflects the value + * the SDK actually enforces, rather than re-decoding the live ID token — which would diverge + * after a refresh whose token omits or re-emits the claim. No-op when nothing is pinned, so the + * getter falls back to the token claim. Returns the same instance for call-site convenience. + */ + protected fun stampPinnedSessionExpiry(credentials: Credentials): Credentials { + storage.retrieveLong(KEY_SESSION_EXPIRY)?.takeIf { it > 0 }?.let { + credentials.pinnedSessionExpiresAt = it + } + return credentials + } + /** * Pins the `session_expiry` ceiling from the initial login and preserves it across refreshes. * @@ -369,9 +385,13 @@ public abstract class BaseCredentialsManager internal constructor( /** * Validates, at session-creation time, that the given ID token is not already past its - * `session_expiry` ceiling (i.e. `session_expiry <= iat`). Throws [CredentialsManagerException] - * with code `SESSION_EXPIRED` when it is, so an already-expired session is never persisted. - * No-op when the token is absent or does not carry both `session_expiry` and `iat`. + * `session_expiry` ceiling. Throws [CredentialsManagerException] with code `SESSION_EXPIRED` + * when it is, so an already-expired session is never persisted. No-op when the token is absent + * or does not carry both `session_expiry` and `iat`. + * + * The same [SESSION_EXPIRY_LEEWAY_SECONDS] leeway used by [isSessionExpired] is applied here so + * the two checks agree: a ceiling within the leeway of `iat` is rejected up front rather than + * being persisted only to be treated as expired on the very next read. */ @Throws(CredentialsManagerException::class) protected fun validateSessionExpiryAtCreation(idToken: String?) { @@ -379,7 +399,7 @@ public abstract class BaseCredentialsManager internal constructor( val jwt = runCatching { jwtDecoder.decode(idToken) }.getOrNull() ?: return val sessionExpiry = jwt.sessionExpiry ?: return val issuedAtSeconds = jwt.issuedAt?.time?.div(1000) ?: return - if (sessionExpiry <= issuedAtSeconds) { + if (sessionExpiry <= issuedAtSeconds + SESSION_EXPIRY_LEEWAY_SECONDS) { throw CredentialsManagerException.SESSION_EXPIRED } } 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 23217fd13..fe11bdd26 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 @@ -490,13 +490,15 @@ public class CredentialsManager @VisibleForTesting(otherwise = VisibleForTesting val scopeChanged = hasScopeChanged(storedScope, scope) if (!forceRefresh && !willAccessTokenExpire && !scopeChanged) { callback.onSuccess( - recreateCredentials( - idToken.orEmpty(), - accessToken.orEmpty(), - tokenType.orEmpty(), - refreshToken, - Date(expiresAt), - storedScope + stampPinnedSessionExpiry( + recreateCredentials( + idToken.orEmpty(), + accessToken.orEmpty(), + tokenType.orEmpty(), + refreshToken, + Date(expiresAt), + storedScope + ) ) ) return@execute @@ -549,7 +551,7 @@ public class CredentialsManager @VisibleForTesting(otherwise = VisibleForTesting fresh.scope ) saveCredentials(credentials) - callback.onSuccess(credentials) + callback.onSuccess(stampPinnedSessionExpiry(credentials)) } catch (error: AuthenticationException) { if (error.isMultifactorRequired) { callback.onFailure( 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 0851f9e74..5d8d9107a 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 @@ -884,7 +884,7 @@ public class SecureCredentialsManager @VisibleForTesting(otherwise = VisibleForT val willAccessTokenExpire = willExpire(expiresAt, minTtl.toLong()) val scopeChanged = hasScopeChanged(credentials.scope, scope) if (!forceRefresh && !willAccessTokenExpire && !scopeChanged) { - callback.onSuccess(credentials) + callback.onSuccess(stampPinnedSessionExpiry(credentials)) return@execute } if (credentials.refreshToken == null) { @@ -969,7 +969,7 @@ public class SecureCredentialsManager @VisibleForTesting(otherwise = VisibleForT try { saveCredentials(freshCredentials) - callback.onSuccess(freshCredentials) + callback.onSuccess(stampPinnedSessionExpiry(freshCredentials)) } catch (error: CredentialsManagerException) { val exception = CredentialsManagerException( CredentialsManagerException.Code.STORE_FAILED, error diff --git a/auth0/src/main/java/com/auth0/android/request/internal/Jwt.kt b/auth0/src/main/java/com/auth0/android/request/internal/Jwt.kt index 9c5f80d27..98eb39fa9 100644 --- a/auth0/src/main/java/com/auth0/android/request/internal/Jwt.kt +++ b/auth0/src/main/java/com/auth0/android/request/internal/Jwt.kt @@ -32,8 +32,9 @@ internal class Jwt(rawToken: String) { /** * The IPSIE `session_expiry` claim: an absolute session-expiry ceiling in **Unix seconds** - * asserted by the upstream identity provider. Null when the connection does not emit the claim, - * which MUST be treated as "no ceiling". + * asserted by the upstream identity provider. Null when the connection does not emit the claim + * or the value is not a plausible Unix-seconds timestamp (see [MAX_PLAUSIBLE_SESSION_EXPIRY]), + * both of which MUST be treated as "no ceiling". */ val sessionExpiry: Long? @@ -60,7 +61,12 @@ internal class Jwt(rawToken: String) { authorizedParty = decodedPayload["azp"] as String? authenticationTime = (decodedPayload["auth_time"] as? Double)?.let { Date(it.toLong() * 1000) } - sessionExpiry = (decodedPayload["session_expiry"] as? Double)?.toLong() + // `session_expiry` is customer-authored and expected in Unix *seconds*. A value mistakenly + // emitted in milliseconds would parse as a timestamp ~50,000 years out and silently disable + // the ceiling (fail-open), so reject implausibly large values and treat them as "no ceiling". + // `as? Number` (not `as? Double`) so a JSON value deserialized as a Long is not dropped. + sessionExpiry = (decodedPayload["session_expiry"] as? Number)?.toLong() + ?.takeIf { it < MAX_PLAUSIBLE_SESSION_EXPIRY } audience = when (val aud = decodedPayload["aud"]) { is String -> listOf(aud) is List<*> -> aud as List @@ -69,6 +75,13 @@ internal class Jwt(rawToken: String) { } companion object { + /** + * Upper bound (exclusive) for a plausible `session_expiry` in Unix seconds: 10,000,000,000 + * (year ~2286). A value at or above this is almost certainly milliseconds and is treated as + * "no ceiling" rather than a date tens of thousands of years out. + */ + private const val MAX_PLAUSIBLE_SESSION_EXPIRY = 10_000_000_000L + fun splitToken(token: String): Array { var parts = token.split(".").toTypedArray() if (parts.size == 2 && token.endsWith(".")) { diff --git a/auth0/src/main/java/com/auth0/android/result/Credentials.kt b/auth0/src/main/java/com/auth0/android/result/Credentials.kt index 86dbf8965..9565b66cd 100755 --- a/auth0/src/main/java/com/auth0/android/result/Credentials.kt +++ b/auth0/src/main/java/com/auth0/android/result/Credentials.kt @@ -79,18 +79,27 @@ public data class Credentials( return gson.fromJson(Jwt.decodeBase64(payload), UserProfile::class.java) } + /** + * The session-expiry ceiling pinned at the initial login, in Unix seconds, stamped by the + * credentials manager when it serves these credentials. When set, it is the value actually + * enforced by the SDK and takes precedence over the live [idToken] claim — so [sessionExpiresAt] + * does not diverge from enforcement after a refresh whose token omits or re-emits the claim. + */ + @Transient + internal var pinnedSessionExpiresAt: Long? = null + /** * The absolute session-expiry ceiling, in **Unix seconds**, asserted by the upstream identity - * provider via the IPSIE `session_expiry` claim in the ID token, or `null` when the connection - * does not emit the claim. + * provider via the IPSIE `session_expiry` claim, or `null` when the connection does not emit it. * * This is a session-level ceiling that is independent of [expiresAt] (the access-token expiry): * it usually sits much further out and caps how long the local session may live, regardless of * access-token renewals. A `null` value means there is no such ceiling. * - * The value is decoded on demand from [idToken] and is not stored as a separate field. + * When these credentials were served by a credentials manager, this reflects the value pinned at + * the initial login (the one the SDK enforces). Otherwise it is decoded on demand from [idToken]. */ public val sessionExpiresAt: Long? - get() = runCatching { Jwt(idToken).sessionExpiry }.getOrNull() + get() = pinnedSessionExpiresAt ?: runCatching { Jwt(idToken).sessionExpiry }.getOrNull() } \ No newline at end of file diff --git a/auth0/src/test/java/com/auth0/android/authentication/storage/CredentialsManagerTest.kt b/auth0/src/test/java/com/auth0/android/authentication/storage/CredentialsManagerTest.kt index 3023855b3..02a7434b1 100644 --- a/auth0/src/test/java/com/auth0/android/authentication/storage/CredentialsManagerTest.kt +++ b/auth0/src/test/java/com/auth0/android/authentication/storage/CredentialsManagerTest.kt @@ -2735,6 +2735,34 @@ public class CredentialsManagerTest { verify(storage).remove("com.auth0.id_token") } + @Test + public fun shouldEnforcePinnedSessionExpiryWhenRefreshedIdTokenReEmitsLaterValue() { + val nowSeconds = CredentialsMock.CURRENT_TIME_MS / 1000 + Mockito.`when`(storage.retrieveString("com.auth0.id_token")).thenReturn("idToken") + Mockito.`when`(storage.retrieveString("com.auth0.access_token")).thenReturn("accessToken") + Mockito.`when`(storage.retrieveString("com.auth0.refresh_token")).thenReturn("refreshToken") + Mockito.`when`(storage.retrieveString("com.auth0.token_type")).thenReturn("type") + Mockito.`when`(storage.retrieveLong("com.auth0.expires_at")) + .thenReturn(CredentialsMock.ONE_HOUR_AHEAD_MS) + Mockito.`when`(storage.retrieveString("com.auth0.scope")).thenReturn("scope") + // The (refreshed) ID token re-emits a *later* session_expiry; it must NOT raise the ceiling. + prepareJwtDecoderMockWithSessionExpiry(nowSeconds + 100_000) + // The pinned ceiling from the initial login is already reached. Stubbed after the helper so + // the storage-first lookup sees this value rather than the helper's default null. + Mockito.`when`(storage.retrieveLong("com.auth0.session_expiry")).thenReturn(nowSeconds - 100) + + manager.getCredentials(callback) + + verify(callback).onFailure(exceptionCaptor.capture()) + MatcherAssert.assertThat( + exceptionCaptor.firstValue, + Is.`is`(CredentialsManagerException.SESSION_EXPIRED) + ) + // Enforcement honors the pinned value, so the refresh-token grant is never used. + verifyZeroInteractions(client) + verify(storage).remove("com.auth0.session_expiry") + } + @Test public fun shouldNotOverwriteStoredSessionExpiryWhenSavingRefreshedCredentials() { val credentials: Credentials = CredentialsMock.create( @@ -2761,6 +2789,11 @@ public class CredentialsManagerTest { val jwtMock = mock() Mockito.`when`(jwtMock.sessionExpiry).thenReturn(sessionExpiry) Mockito.`when`(jwtDecoder.decode("idToken")).thenReturn(jwtMock) + // No value is pinned in storage by default, so the ceiling resolves from the idToken claim + // above. (Mockito returns 0L for an unstubbed Long?-returning property, which the + // storage-first lookup in isSessionExpired would otherwise consume as a bogus ceiling.) + // Tests that exercise a pinned value stub this key explicitly *after* calling this helper. + Mockito.`when`(storage.retrieveLong("com.auth0.session_expiry")).thenReturn(null) } private fun prepareJwtDecoderMock(expiresAt: Date?) { diff --git a/auth0/src/test/java/com/auth0/android/authentication/storage/SecureCredentialsManagerTest.kt b/auth0/src/test/java/com/auth0/android/authentication/storage/SecureCredentialsManagerTest.kt index 61a5035bf..109c993f2 100644 --- a/auth0/src/test/java/com/auth0/android/authentication/storage/SecureCredentialsManagerTest.kt +++ b/auth0/src/test/java/com/auth0/android/authentication/storage/SecureCredentialsManagerTest.kt @@ -3728,6 +3728,10 @@ public class SecureCredentialsManagerTest { willExpireAt = Date(CredentialsMock.ONE_HOUR_AHEAD_MS), scope = "scope" ) + // Nothing pinned in storage, so the ceiling resolves from the decrypted ID token claim + // below. (Mockito returns 0L for the unstubbed key, which the storage-first lookup in + // isSessionExpired would otherwise consume as a bogus "no ceiling" value.) + Mockito.`when`(storage.retrieveLong("com.auth0.session_expiry")).thenReturn(null) // The decrypted ID token carries a session_expiry ceiling already in the past. val jwtMock = mock() Mockito.`when`(jwtMock.sessionExpiry).thenReturn(nowSeconds - 100) diff --git a/auth0/src/test/java/com/auth0/android/request/internal/JwtTest.kt b/auth0/src/test/java/com/auth0/android/request/internal/JwtTest.kt index cdff660fb..d9a6ffcb0 100644 --- a/auth0/src/test/java/com/auth0/android/request/internal/JwtTest.kt +++ b/auth0/src/test/java/com/auth0/android/request/internal/JwtTest.kt @@ -238,6 +238,16 @@ public class JwtTest { assertThat(jwt.sessionExpiry, `is`(1700000000L)) } + @Test + public fun shouldGetNullSessionExpiryIfImplausiblyLarge() { + // A value mistakenly emitted in milliseconds (1700000000000) is ~50,000 years out in seconds + // and would silently disable the ceiling; it must be treated as "no ceiling" (null) instead. + val jwt = Jwt(jwtWithPayload("""{"session_expiry":1700000000000}""")) + assertThat(jwt, `is`(notNullValue())) + + assertThat(jwt.sessionExpiry, `is`(nullValue())) + } + /** * Builds a JWT with a fixed `alg=HS256` header and a dummy signature, encoding the given JSON * payload so that [Jwt] can decode it. The signature is never verified by [Jwt]. diff --git a/auth0/src/test/java/com/auth0/android/result/CredentialsTest.kt b/auth0/src/test/java/com/auth0/android/result/CredentialsTest.kt index b06f1f194..e8d6a272d 100644 --- a/auth0/src/test/java/com/auth0/android/result/CredentialsTest.kt +++ b/auth0/src/test/java/com/auth0/android/result/CredentialsTest.kt @@ -108,6 +108,28 @@ public class CredentialsTest { MatcherAssert.assertThat(credentials.sessionExpiresAt, Matchers.`is`(Matchers.nullValue())) } + @Test + public fun shouldPreferPinnedSessionExpiresAtOverIdTokenClaim() { + // The manager stamps the value pinned at login; it must take precedence over a (later) + // claim re-emitted on the current ID token so the public value matches what is enforced. + val credentials = Credentials( + jwtWithPayload("""{"session_expiry":1700000000}"""), + "accessToken", "type", "refreshToken", Date(), "scope" + ) + credentials.pinnedSessionExpiresAt = 1690000000L + MatcherAssert.assertThat(credentials.sessionExpiresAt, Matchers.`is`(1690000000L)) + } + + @Test + public fun shouldFallBackToIdTokenClaimWhenNoPinnedSessionExpiresAt() { + val credentials = Credentials( + jwtWithPayload("""{"session_expiry":1700000000}"""), + "accessToken", "type", "refreshToken", Date(), "scope" + ) + // No pinned value (credentials not served by a manager) -> decode from the ID token. + MatcherAssert.assertThat(credentials.sessionExpiresAt, Matchers.`is`(1700000000L)) + } + private fun jwtWithPayload(jsonPayload: String): String { val header = encode("""{"alg":"HS256","typ":"JWT"}""") val payload = encode(jsonPayload) From 4f77b20bee76406c4380cede36489abe468f0ea6 Mon Sep 17 00:00:00 2001 From: utkrishtS Date: Thu, 25 Jun 2026 14:21:10 +0530 Subject: [PATCH 49/60] fix: harden session_expiry fallback and add pinned-ceiling coverage --- EXAMPLES.md | 1 + .../storage/BaseCredentialsManager.kt | 12 ++++----- .../storage/CredentialsManagerTest.kt | 26 +++++++++++++++++++ 3 files changed, 32 insertions(+), 7 deletions(-) diff --git a/EXAMPLES.md b/EXAMPLES.md index 1c617d7af..6da60b053 100644 --- a/EXAMPLES.md +++ b/EXAMPLES.md @@ -2937,6 +2937,7 @@ When an enterprise connection (for example an OIDC or Okta connection) is config The credentials managers enforce this ceiling automatically: - The ceiling is read from the ID token at login and persisted, so it survives refreshes whose ID token does not re-emit the claim. +- `saveCredentials` rejects an already-expired session up front: if the ID token is already past its ceiling at login, the save throws `CredentialsManagerException.SESSION_EXPIRED` and nothing is persisted. - On every `getCredentials` call, if the ceiling has been reached the stored credentials are cleared and the call fails with `CredentialsManagerException.SESSION_EXPIRED`. The refresh token is **never** used to renew a session past the ceiling. - A small negative clock-skew leeway (~30 seconds) is applied, so the session is treated as expired slightly *before* the wall-clock ceiling, never after. - Connections that do not emit the claim are unaffected — there is no ceiling and behavior is unchanged. diff --git a/auth0/src/main/java/com/auth0/android/authentication/storage/BaseCredentialsManager.kt b/auth0/src/main/java/com/auth0/android/authentication/storage/BaseCredentialsManager.kt index a4a8184d9..1a4e8b09b 100644 --- a/auth0/src/main/java/com/auth0/android/authentication/storage/BaseCredentialsManager.kt +++ b/auth0/src/main/java/com/auth0/android/authentication/storage/BaseCredentialsManager.kt @@ -338,14 +338,12 @@ public abstract class BaseCredentialsManager internal constructor( * before the wall-clock ceiling, never after. */ protected fun isSessionExpired(idToken: String?): Boolean { - val sessionExpiry = storage.retrieveLong(KEY_SESSION_EXPIRY) - ?: sessionExpiryFromIdToken(idToken) + // A non-positive value is not a valid Unix timestamp; treat it as "not pinned"/"no ceiling" + // (mirrors the unset/migration guard in [willExpire]) so a 0/negative stored sentinel falls + // through to the live claim rather than fail-open as "no ceiling". + val sessionExpiry = storage.retrieveLong(KEY_SESSION_EXPIRY)?.takeIf { it > 0 } + ?: sessionExpiryFromIdToken(idToken)?.takeIf { it > 0 } ?: return false - // A non-positive ceiling is not a valid Unix timestamp; treat it as "no ceiling" rather than - // already-expired (mirrors the guard in [willExpire] for unset/migration values). - if (sessionExpiry <= 0) { - return false - } val nowSeconds = currentTimeInMillis / 1000 return nowSeconds + SESSION_EXPIRY_LEEWAY_SECONDS >= sessionExpiry } diff --git a/auth0/src/test/java/com/auth0/android/authentication/storage/CredentialsManagerTest.kt b/auth0/src/test/java/com/auth0/android/authentication/storage/CredentialsManagerTest.kt index 02a7434b1..f6cb22b89 100644 --- a/auth0/src/test/java/com/auth0/android/authentication/storage/CredentialsManagerTest.kt +++ b/auth0/src/test/java/com/auth0/android/authentication/storage/CredentialsManagerTest.kt @@ -2615,6 +2615,32 @@ public class CredentialsManagerTest { verifyZeroInteractions(client) } + @Test + public fun shouldReturnCredentialsCarryingPinnedSessionExpiresAt() { + val nowSeconds = CredentialsMock.CURRENT_TIME_MS / 1000 + val pinnedCeiling = nowSeconds + 100_000 + Mockito.`when`(storage.retrieveString("com.auth0.id_token")).thenReturn("idToken") + Mockito.`when`(storage.retrieveString("com.auth0.access_token")).thenReturn("accessToken") + Mockito.`when`(storage.retrieveString("com.auth0.refresh_token")).thenReturn("refreshToken") + Mockito.`when`(storage.retrieveString("com.auth0.token_type")).thenReturn("type") + // Access token not yet expired -> cached credentials are served without a refresh. + Mockito.`when`(storage.retrieveLong("com.auth0.expires_at")) + .thenReturn(CredentialsMock.ONE_HOUR_AHEAD_MS) + Mockito.`when`(storage.retrieveString("com.auth0.scope")).thenReturn("scope") + prepareJwtDecoderMockWithSessionExpiry(pinnedCeiling) + // Pinned at login. The returned credentials must carry this value via stampPinnedSessionExpiry, + // not a value re-decoded from the live ID token. Stubbed after the helper's default null. + Mockito.`when`(storage.retrieveLong("com.auth0.session_expiry")).thenReturn(pinnedCeiling) + + manager.getCredentials(callback) + + verify(callback).onSuccess(credentialsCaptor.capture()) + MatcherAssert.assertThat( + credentialsCaptor.firstValue.sessionExpiresAt, + Is.`is`(pinnedCeiling) + ) + } + @Test public fun shouldNotEnforceSessionExpiryWhenClaimAndStoredValueAreAbsent() { Mockito.`when`(storage.retrieveString("com.auth0.id_token")).thenReturn("idToken") From d9f8db1f18a9067592bda234d790ed22d2cee97f Mon Sep 17 00:00:00 2001 From: Prince Mathew <17837162+pmathew92@users.noreply.github.com> Date: Thu, 25 Jun 2026 15:10:47 +0530 Subject: [PATCH 50/60] feat : Added passwordless API for DB-Connections (#989) --- .gitignore | 3 +- EXAMPLES.md | 121 +++++++ .../authentication/AuthenticationAPIClient.kt | 22 ++ .../passwordless/DeliveryMethod.kt | 17 + .../passwordless/PasswordlessClient.kt | 302 ++++++++++++++++++ .../android/result/PasswordlessChallenge.kt | 17 + .../AuthenticationAPIClientTest.kt | 6 + .../authentication/PasswordlessClientTest.kt | 284 ++++++++++++++++ 8 files changed, 771 insertions(+), 1 deletion(-) create mode 100644 auth0/src/main/java/com/auth0/android/authentication/passwordless/DeliveryMethod.kt create mode 100644 auth0/src/main/java/com/auth0/android/authentication/passwordless/PasswordlessClient.kt create mode 100644 auth0/src/main/java/com/auth0/android/result/PasswordlessChallenge.kt create mode 100644 auth0/src/test/java/com/auth0/android/authentication/PasswordlessClientTest.kt diff --git a/.gitignore b/.gitignore index 8becb5216..0d2a32b2e 100644 --- a/.gitignore +++ b/.gitignore @@ -99,4 +99,5 @@ gen-external-apklibs version.txt # Internal planning docs -plans/ \ No newline at end of file +plans/ +docs/ \ No newline at end of file diff --git a/EXAMPLES.md b/EXAMPLES.md index 1520f3cff..e955a3c5f 100644 --- a/EXAMPLES.md +++ b/EXAMPLES.md @@ -27,6 +27,9 @@ - [Passwordless Login](#passwordless-login) - [Step 1: Request the code](#step-1-request-the-code) - [Step 2: Input the code](#step-2-input-the-code) + - [Passwordless Login with a Database Connection (EA)](#passwordless-login-with-a-database-connection-ea) + - [Step 1: Issue an OTP challenge](#step-1-issue-an-otp-challenge) + - [Step 2: Verify the code and log in](#step-2-verify-the-code-and-log-in) - [Sign Up with a database connection](#sign-up-with-a-database-connection) - [Get user information](#get-user-information) - [Custom Token Exchange](#custom-token-exchange) @@ -1488,6 +1491,124 @@ authentication > The default scope used is `openid profile email`. Regardless of the scopes set to the request, the `openid` scope is always enforced. +### Passwordless Login with a Database Connection (EA) + +> [!IMPORTANT] +> Passwordless Login for database connections is currently 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. + +This flow lets users authenticate with a one-time code sent over email or SMS/voice against a **database connection** that has `email_otp` or `phone_otp` enabled. It is distinct from the `/passwordless/start` flow described above, which uses dedicated passwordless connections. + +Obtain a `PasswordlessClient` from the `AuthenticationAPIClient`: + +```kotlin +val passwordless = AuthenticationAPIClient(account).passwordlessClient() +``` + +The flow has two steps: first issue an OTP challenge, then — after the user enters the code they received — exchange it for credentials. **Save the `PasswordlessChallenge` from step 1**, as you pass that same object into `loginWithOTP` in step 2. + +#### Step 1: Issue an OTP challenge + +Send a one-time code to the user's email. For privacy, the server **always responds successfully regardless of whether the user exists**. On success, save the returned `PasswordlessChallenge` for step 2. + +```kotlin +// keep this reference until the user enters the code +var challenge: PasswordlessChallenge? = null + +passwordless + .challengeWithEmail("info@auth0.com", "my-database-connection") + .start(object: Callback { + override fun onFailure(exception: AuthenticationException) { } + + override fun onSuccess(result: PasswordlessChallenge) { + challenge = result + } + }) +``` + +To send the code over SMS or voice instead, use `challengeWithPhoneNumber` against a connection with `phone_otp` enabled, choosing the `DeliveryMethod`: + +```kotlin +passwordless + .challengeWithPhoneNumber("+15555550123", "my-database-connection", DeliveryMethod.TEXT) + .start(object: Callback { + override fun onFailure(exception: AuthenticationException) { } + + override fun onSuccess(result: PasswordlessChallenge) { + challenge = result + } + }) +``` + +Both challenge methods accept an optional `allowSignup` parameter (defaults to `false`) that controls whether a new user is created if one does not yet exist. + +#### Step 2: Verify the code and log in + +Once the user enters the code, pass the saved `challenge` together with that code to `loginWithOTP` to obtain `Credentials`. If DPoP is enabled on the originating `AuthenticationAPIClient`, a DPoP proof is attached automatically to this token request. + +```kotlin +passwordless + .loginWithOTP(challenge, "123456") + .start(object: Callback { + override fun onFailure(exception: AuthenticationException) { } + + override fun onSuccess(credentials: Credentials) { } + }) +``` + +
+ Using coroutines + +```kotlin +// Step 1: issue the challenge and keep it +val challenge = passwordless + .challengeWithEmail("info@auth0.com", "my-database-connection") + .await() + +// Step 2: once the user enters the code, pass the saved challenge back to log in +val credentials = passwordless + .loginWithOTP(challenge, "123456") + .await() +``` +
+ +
+ Using Java + +```java +// Step 1: issue the challenge and keep it +passwordless + .challengeWithEmail("info@auth0.com", "my-database-connection", false) + .start(new Callback() { + @Override + public void onSuccess(PasswordlessChallenge result) { + challenge = result; + } + + @Override + public void onFailure(@NonNull AuthenticationException error) { + //Error! + } + }); + +// Step 2: once the user enters the code, pass the saved challenge back to log in +passwordless + .loginWithOTP(challenge, "123456") + .start(new Callback() { + @Override + public void onSuccess(@Nullable Credentials payload) { + //Logged in! + } + + @Override + public void onFailure(@NonNull AuthenticationException error) { + //Error! + } + }); +``` +
+ +> The default scope used is `openid profile email`. Regardless of the scopes set to the request, the `openid` scope is always enforced. + ### Sign Up with a database connection ```kotlin 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 94b956f3b..b80f34873 100755 --- a/auth0/src/main/java/com/auth0/android/authentication/AuthenticationAPIClient.kt +++ b/auth0/src/main/java/com/auth0/android/authentication/AuthenticationAPIClient.kt @@ -6,6 +6,7 @@ import com.auth0.android.Auth0 import com.auth0.android.Auth0Exception import com.auth0.android.NetworkErrorException import com.auth0.android.authentication.mfa.MfaApiClient +import com.auth0.android.authentication.passwordless.PasswordlessClient import com.auth0.android.authentication.request.ActorToken import com.auth0.android.dpop.DPoP import com.auth0.android.dpop.DPoPException @@ -118,6 +119,27 @@ public class AuthenticationAPIClient @VisibleForTesting(otherwise = VisibleForTe return MfaApiClient(this.auth0, mfaToken) } + /** + * Creates a [PasswordlessClient] for the database-connection passwordless flow. + * + * ## 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. + * + * ## Usage + * + * ```kotlin + * val passwordless = authClient.passwordlessClient() + * ``` + * + * @return a new [PasswordlessClient] instance bound to this client's Auth0 account. + */ + public fun passwordlessClient(): PasswordlessClient { + return PasswordlessClient(this.auth0, gson, this.dPoP) + } + /** * Log in a user with email/username and password for a connection/realm. * It will use the password-realm grant type for the `/oauth/token` endpoint diff --git a/auth0/src/main/java/com/auth0/android/authentication/passwordless/DeliveryMethod.kt b/auth0/src/main/java/com/auth0/android/authentication/passwordless/DeliveryMethod.kt new file mode 100644 index 000000000..f4abbbb0e --- /dev/null +++ b/auth0/src/main/java/com/auth0/android/authentication/passwordless/DeliveryMethod.kt @@ -0,0 +1,17 @@ +package com.auth0.android.authentication.passwordless + +/** + * Delivery method for a phone-number OTP challenge. + * + * Maps to the `delivery_method` request parameter of `POST /otp/challenge`. [TEXT] sends the + * one-time code via SMS (the server default); [VOICE] delivers it through a voice call. + * + * @property value the wire value sent to the server. + */ +public enum class DeliveryMethod(public val value: String) { + /** Deliver the one-time code via SMS. */ + TEXT("text"), + + /** Deliver the one-time code via a voice call. */ + VOICE("voice") +} diff --git a/auth0/src/main/java/com/auth0/android/authentication/passwordless/PasswordlessClient.kt b/auth0/src/main/java/com/auth0/android/authentication/passwordless/PasswordlessClient.kt new file mode 100644 index 000000000..117fbcaa7 --- /dev/null +++ b/auth0/src/main/java/com/auth0/android/authentication/passwordless/PasswordlessClient.kt @@ -0,0 +1,302 @@ +package com.auth0.android.authentication.passwordless + +import androidx.annotation.VisibleForTesting +import com.auth0.android.Auth0 +import com.auth0.android.Auth0Exception +import com.auth0.android.NetworkErrorException +import com.auth0.android.authentication.AuthenticationException +import com.auth0.android.authentication.ParameterBuilder +import com.auth0.android.dpop.DPoP +import com.auth0.android.dpop.DPoPException +import com.auth0.android.request.AuthenticationRequest +import com.auth0.android.request.ErrorAdapter +import com.auth0.android.request.JsonAdapter +import com.auth0.android.request.Request +import com.auth0.android.request.RequestOptions +import com.auth0.android.request.RequestValidator +import com.auth0.android.request.internal.BaseAuthenticationRequest +import com.auth0.android.request.internal.GsonAdapter +import com.auth0.android.request.internal.GsonProvider +import com.auth0.android.request.internal.RequestFactory +import com.auth0.android.request.internal.ResponseUtils.isNetworkError +import com.auth0.android.result.Credentials +import com.auth0.android.result.PasswordlessChallenge +import com.google.gson.Gson +import okhttp3.HttpUrl.Companion.toHttpUrl +import java.io.IOException +import java.io.Reader + +/** + * API client for the database-connection passwordless authentication flow. + * + * Obtain an instance from + * [com.auth0.android.authentication.AuthenticationAPIClient.passwordlessClient]. + * + * ## 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. + * + * @see com.auth0.android.authentication.AuthenticationAPIClient.passwordlessClient + */ +public class PasswordlessClient @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) internal constructor( + private val auth0: Auth0, + private val gson: Gson, + private val dPoP: DPoP? +) { + + private val requestFactory: RequestFactory by lazy { + RequestFactory(auth0.networkingClient, createErrorAdapter()).apply { + setAuth0ClientInfo(auth0.auth0UserAgent.value) + } + } + + /** + * Creates a new PasswordlessClient instance. + * + * @param auth0 the Auth0 account information. + */ + public constructor(auth0: Auth0) : this(auth0, GsonProvider.gson, null) + + private val clientId: String = auth0.clientId + private val baseURL: String = auth0.getDomainUrl() + + /** + * Issues an OTP challenge to an email address for a database connection. + * + * Sends a one-time code to the given email for a connection that has `email_otp` enabled. + * For privacy, the server **always responds successfully regardless of whether the user + * exists** (user-enumeration prevention). On success an opaque [PasswordlessChallenge.authSession] + * is returned — pass it to [loginWithOTP] together with the code the user receives. + * + * ## 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. + * + * ## Usage + * + * ```kotlin + * passwordless.challengeWithEmail("user@example.com", "Username-Password-Authentication") + * .start(object : Callback { + * override fun onSuccess(result: PasswordlessChallenge) { + * val challenge = result + * } + * override fun onFailure(error: AuthenticationException) { } + * }) + * ``` + * + * @param email the email address to send the one-time code to. + * @param connection the name of the database connection; it must have `email_otp` enabled. + * @param allowSignup whether to allow sign-up if the user does not yet exist. Defaults to `false`. + * @return a request that, when started, yields a [PasswordlessChallenge] containing the `auth_session`. + * @see loginWithOTP + */ + @JvmOverloads + public fun challengeWithEmail( + email: String, + connection: String, + allowSignup: Boolean = false + ): Request { + val parameters = ParameterBuilder.newBuilder() + .setClientId(clientId) + .setConnection(connection) + .set(ALLOW_SIGNUP_KEY, allowSignup.toString()) + .set(EMAIL_KEY, email) + .asDictionary() + return challengeRequest(parameters).addValidator(object : RequestValidator { + override fun validate(options: RequestOptions) { + requireNotBlank(email, EMAIL_KEY) + requireNotBlank(connection, CONNECTION_KEY) + } + }) + } + + /** + * Issues an OTP challenge to a phone number for a database connection. + * + * Sends a one-time code to the given phone number for a connection that has `phone_otp` + * enabled, delivered either by SMS or voice call per [deliveryMethod]. For privacy, the server + * **always responds successfully regardless of whether the user exists**. + * + * ## 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. + * + * ## Usage + * + * ```kotlin + * passwordless.challengeWithPhoneNumber("+15555550123", "Username-Password-Authentication", DeliveryMethod.TEXT) + * .start(object : Callback { + * override fun onSuccess(result: PasswordlessChallenge) { + * val challenge = result + * } + * override fun onFailure(error: AuthenticationException) { } + * }) + * ``` + * + * @param phoneNumber the E.164 phone number to send the one-time code to (e.g. `"+15555550123"`). + * @param connection the name of the database connection; it must have `phone_otp` enabled. + * @param deliveryMethod how to deliver the code. Defaults to [DeliveryMethod.TEXT]. + * @param allowSignup whether to allow sign-up if the user does not yet exist. Defaults to `false`. + * @return a request that, when started, yields a [PasswordlessChallenge] containing the `auth_session`. + * @see loginWithOTP + */ + @JvmOverloads + public fun challengeWithPhoneNumber( + phoneNumber: String, + connection: String, + deliveryMethod: DeliveryMethod = DeliveryMethod.TEXT, + allowSignup: Boolean = false + ): Request { + val parameters = ParameterBuilder.newBuilder() + .setClientId(clientId) + .setConnection(connection) + .set(ALLOW_SIGNUP_KEY, allowSignup.toString()) + .set(PHONE_NUMBER_KEY, phoneNumber) + .set(DELIVERY_METHOD_KEY, deliveryMethod.value) + .asDictionary() + return challengeRequest(parameters).addValidator(object : RequestValidator { + override fun validate(options: RequestOptions) { + requireNotBlank(phoneNumber, PHONE_NUMBER_KEY) + requireNotBlank(connection, CONNECTION_KEY) + } + }) + } + + /** + * Completes the OTP flow by verifying the one-time code and obtaining credentials. + * + * Exchanges the opaque `auth_session` returned by [challengeWithEmail] or + * [challengeWithPhoneNumber], together with the code the user received, for [Credentials] using + * the passwordless OTP grant on `POST /oauth/token`. When DPoP is enabled on the originating + * [com.auth0.android.authentication.AuthenticationAPIClient], a DPoP proof is attached. + * + * ## 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. + * + * ## Usage + * + * ```kotlin + * passwordless.loginWithOTP(challenge, "123456") + * .start(object : Callback { + * override fun onSuccess(result: Credentials) { } + * override fun onFailure(error: AuthenticationException) { } + * }) + * ``` + * + * @param passwordlessChallenge the challenge from a prior challenge (see [PasswordlessChallenge]). + * @param otp the one-time code the user received via email, SMS, or voice call. + * @return a request that, when started, yields [Credentials] on success. + * @see challengeWithEmail + * @see challengeWithPhoneNumber + */ + public fun loginWithOTP( + passwordlessChallenge: PasswordlessChallenge, + otp: String + ): AuthenticationRequest { + val url = baseURL.toHttpUrl().newBuilder() + .addPathSegment(OAUTH_PATH) + .addPathSegment(TOKEN_PATH) + .build() + + val parameters = ParameterBuilder.newAuthenticationBuilder() + .setClientId(clientId) + .setGrantType(ParameterBuilder.GRANT_TYPE_PASSWORDLESS_OTP) + .set(AUTH_SESSION_KEY, passwordlessChallenge.authSession) + .set(ONE_TIME_PASSWORD_KEY, otp) + .asDictionary() + + val credentialsAdapter: JsonAdapter = + GsonAdapter(Credentials::class.java, gson) + + val request = BaseAuthenticationRequest( + requestFactory.post(url.toString(), credentialsAdapter, dPoP), clientId, baseURL + ).apply { + addParameters(parameters) + addValidator(object : RequestValidator { + override fun validate(options: RequestOptions) { + requireNotBlank(otp, ONE_TIME_PASSWORD_KEY) + } + }) + } + return request + } + + private fun challengeRequest( + parameters: Map + ): Request { + val url = baseURL.toHttpUrl().newBuilder() + .addPathSegment(OTP_PATH) + .addPathSegment(CHALLENGE_PATH) + .build() + + val challengeAdapter: JsonAdapter = + GsonAdapter(PasswordlessChallenge::class.java, gson) + + return requestFactory.post(url.toString(), challengeAdapter) + .addParameters(parameters) + } + + private fun requireNotBlank(value: String, name: String) { + if (value.isBlank()) { + throw AuthenticationException(INVALID_REQUEST, "$name is required") + } + } + + private fun createErrorAdapter(): ErrorAdapter { + val mapAdapter = GsonAdapter.forMap(gson) + return object : ErrorAdapter { + override fun fromRawResponse( + statusCode: Int, bodyText: String, headers: Map> + ): AuthenticationException = AuthenticationException(bodyText, statusCode) + + @Throws(IOException::class) + override fun fromJsonResponse( + statusCode: Int, + reader: Reader + ): AuthenticationException { + val values = mapAdapter.fromJson(reader) + return AuthenticationException(values, statusCode) + } + + override fun fromException(cause: Throwable): AuthenticationException { + if (isNetworkError(cause)) { + return AuthenticationException( + "Failed to execute the network request", NetworkErrorException(cause) + ) + } + if (cause is DPoPException) { + return AuthenticationException( + cause.message ?: "Error while attaching DPoP proof", cause + ) + } + return AuthenticationException( + "Something went wrong", Auth0Exception("Something went wrong", cause) + ) + } + } + } + + private companion object { + private const val OTP_PATH = "otp" + private const val CHALLENGE_PATH = "challenge" + private const val OAUTH_PATH = "oauth" + private const val TOKEN_PATH = "token" + private const val EMAIL_KEY = "email" + private const val PHONE_NUMBER_KEY = "phone_number" + private const val DELIVERY_METHOD_KEY = "delivery_method" + private const val ALLOW_SIGNUP_KEY = "allow_signup" + private const val CONNECTION_KEY = "connection" + private const val AUTH_SESSION_KEY = "auth_session" + private const val ONE_TIME_PASSWORD_KEY = "otp" + private const val INVALID_REQUEST = "invalid_request" + } +} diff --git a/auth0/src/main/java/com/auth0/android/result/PasswordlessChallenge.kt b/auth0/src/main/java/com/auth0/android/result/PasswordlessChallenge.kt new file mode 100644 index 000000000..36e4f8e55 --- /dev/null +++ b/auth0/src/main/java/com/auth0/android/result/PasswordlessChallenge.kt @@ -0,0 +1,17 @@ +package com.auth0.android.result + +import com.auth0.android.request.internal.JsonRequired +import com.google.gson.annotations.SerializedName + +/** + * Result of a passwordless challenge. + * + * Holds the opaque `auth_session` token returned when a passwordless challenge is issued. + * + * @see [com.auth0.android.authentication.passwordless.PasswordlessClient.challengeWithEmail] + * @see [com.auth0.android.authentication.passwordless.PasswordlessClient.challengeWithPhoneNumber] + */ +public class PasswordlessChallenge( + @field:JsonRequired @field:SerializedName("auth_session") + public val authSession: String +) diff --git a/auth0/src/test/java/com/auth0/android/authentication/AuthenticationAPIClientTest.kt b/auth0/src/test/java/com/auth0/android/authentication/AuthenticationAPIClientTest.kt index 72ca29fe1..f0546a2ac 100755 --- a/auth0/src/test/java/com/auth0/android/authentication/AuthenticationAPIClientTest.kt +++ b/auth0/src/test/java/com/auth0/android/authentication/AuthenticationAPIClientTest.kt @@ -141,6 +141,12 @@ public class AuthenticationAPIClientTest { assertThat(client.baseURL.toHttpUrlOrNull()!!.encodedPath, Matchers.`is`("/")) } + @Test + public fun shouldCreatePasswordlessClient() { + val client = AuthenticationAPIClient(Auth0.getInstance(CLIENT_ID, DOMAIN)) + assertThat(client.passwordlessClient(), Matchers.`is`(Matchers.notNullValue())) + } + @Test public fun shouldCreateClientWithContextInfo() { val context: Context = mock() diff --git a/auth0/src/test/java/com/auth0/android/authentication/PasswordlessClientTest.kt b/auth0/src/test/java/com/auth0/android/authentication/PasswordlessClientTest.kt new file mode 100644 index 000000000..9403058ac --- /dev/null +++ b/auth0/src/test/java/com/auth0/android/authentication/PasswordlessClientTest.kt @@ -0,0 +1,284 @@ +package com.auth0.android.authentication + +import android.content.Context +import com.auth0.android.Auth0 +import com.auth0.android.authentication.passwordless.DeliveryMethod +import com.auth0.android.authentication.passwordless.PasswordlessClient +import com.auth0.android.dpop.DPoPKeyStore +import com.auth0.android.dpop.DPoPUtil +import com.auth0.android.dpop.FakeECPrivateKey +import com.auth0.android.dpop.FakeECPublicKey +import com.auth0.android.request.internal.ThreadSwitcherShadow +import com.auth0.android.result.Credentials +import com.auth0.android.result.PasswordlessChallenge +import com.auth0.android.util.SSLTestUtils +import com.google.gson.Gson +import com.google.gson.GsonBuilder +import com.google.gson.reflect.TypeToken +import com.nhaarman.mockitokotlin2.mock +import com.nhaarman.mockitokotlin2.whenever +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import okhttp3.mockwebserver.RecordedRequest +import org.hamcrest.MatcherAssert.assertThat +import org.hamcrest.Matchers.`is` +import org.hamcrest.Matchers.containsString +import org.hamcrest.Matchers.hasEntry +import org.hamcrest.Matchers.notNullValue +import org.hamcrest.Matchers.nullValue +import org.junit.After +import org.junit.Assert.assertThrows +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(shadows = [ThreadSwitcherShadow::class]) +@OptIn(ExperimentalCoroutinesApi::class) +public class PasswordlessClientTest { + + private lateinit var mockServer: MockWebServer + private lateinit var auth0: Auth0 + private lateinit var passwordlessClient: PasswordlessClient + private lateinit var gson: Gson + private lateinit var mockKeyStore: DPoPKeyStore + private lateinit var mockContext: Context + + @Before + public fun setUp() { + mockServer = SSLTestUtils.createMockWebServer() + mockServer.start() + val domain = mockServer.url("/").toString() + auth0 = Auth0.getInstance(CLIENT_ID, domain, domain) + auth0.networkingClient = SSLTestUtils.testClient + passwordlessClient = PasswordlessClient(auth0) + gson = GsonBuilder().serializeNulls().create() + mockKeyStore = mock() + mockContext = mock() + whenever(mockContext.applicationContext).thenReturn(mockContext) + DPoPUtil.keyStore = mockKeyStore + } + + @After + public fun tearDown() { + mockServer.shutdown() + } + + private fun enqueueMockResponse(json: String, statusCode: Int = 200) { + mockServer.enqueue( + MockResponse() + .setResponseCode(statusCode) + .addHeader("Content-Type", "application/json") + .setBody(json) + ) + } + + private fun enqueueErrorResponse(error: String, description: String, statusCode: Int = 400) { + enqueueMockResponse("""{"error": "$error", "error_description": "$description"}""", statusCode) + } + + private inline fun bodyFromRequest(request: RecordedRequest): Map { + val mapType = object : TypeToken>() {}.type + return gson.fromJson(request.body.readUtf8(), mapType) + } + + @Test + public fun shouldCreateClient() { + assertThat(PasswordlessClient(auth0), `is`(notNullValue())) + } + + @Test + public fun shouldChallengeWithEmailHitOtpChallengeWithCorrectParams(): Unit = runTest { + enqueueMockResponse("""{"auth_session": "session_abc"}""") + + val challenge = passwordlessClient + .challengeWithEmail("user@example.com", CONNECTION, allowSignup = true) + .await() + + val request = mockServer.takeRequest() + assertThat(request.path, `is`("/otp/challenge")) + assertThat(request.method, `is`("POST")) + val body = bodyFromRequest(request) + assertThat(body, hasEntry("client_id", CLIENT_ID)) + assertThat(body, hasEntry("connection", CONNECTION)) + assertThat(body, hasEntry("email", "user@example.com")) + assertThat(body, hasEntry("allow_signup", "true")) + assertThat(challenge.authSession, `is`("session_abc")) + } + + @Test + public fun shouldChallengeWithEmailDefaultAllowSignupFalse(): Unit = runTest { + enqueueMockResponse("""{"auth_session": "session_abc"}""") + + passwordlessClient.challengeWithEmail("user@example.com", CONNECTION).await() + + val body = bodyFromRequest(mockServer.takeRequest()) + assertThat(body, hasEntry("allow_signup", "false")) + } + + @Test + public fun shouldChallengeWithPhoneNumberHitOtpChallengeWithCorrectParams(): Unit = runTest { + enqueueMockResponse("""{"auth_session": "session_abc"}""") + + val challenge = passwordlessClient.challengeWithPhoneNumber( + "+15555550123", CONNECTION, DeliveryMethod.VOICE, allowSignup = true + ).await() + + val request = mockServer.takeRequest() + assertThat(request.path, `is`("/otp/challenge")) + val body = bodyFromRequest(request) + assertThat(body, hasEntry("client_id", CLIENT_ID)) + assertThat(body, hasEntry("connection", CONNECTION)) + assertThat(body, hasEntry("phone_number", "+15555550123")) + assertThat(body, hasEntry("delivery_method", "voice")) + assertThat(body, hasEntry("allow_signup", "true")) + assertThat(challenge.authSession, `is`("session_abc")) + } + + @Test + public fun shouldChallengeWithPhoneNumberDefaultDeliveryMethodText(): Unit = runTest { + enqueueMockResponse("""{"auth_session": "session_abc"}""") + + passwordlessClient.challengeWithPhoneNumber("+15555550123", CONNECTION).await() + + val body = bodyFromRequest(mockServer.takeRequest()) + assertThat(body, hasEntry("delivery_method", "text")) + } + + @Test + public fun shouldIncludeAuth0ClientHeaderInChallenge(): Unit = runTest { + enqueueMockResponse("""{"auth_session": "session_abc"}""") + + passwordlessClient.challengeWithEmail("user@example.com", CONNECTION).await() + + assertThat(mockServer.takeRequest().getHeader("Auth0-Client"), `is`(notNullValue())) + } + + @Test + public fun shouldPropagateChallengeApiError() { + enqueueErrorResponse("invalid_connection", "Connection does not exist", 400) + + val exception = assertThrows(AuthenticationException::class.java) { + runTest { passwordlessClient.challengeWithEmail("user@example.com", CONNECTION).await() } + } + assertThat(exception.getCode(), `is`("invalid_connection")) + } + + @Test + public fun shouldFailChallengeWithBlankEmailWithoutNetworkCall() { + val exception = assertThrows(AuthenticationException::class.java) { + runTest { passwordlessClient.challengeWithEmail("", CONNECTION).await() } + } + assertThat(exception.getCode(), `is`("invalid_request")) + assertThat(exception.getDescription(), containsString("email is required")) + assertThat(mockServer.requestCount, `is`(0)) + } + + @Test + public fun shouldFailChallengeWithBlankConnectionWithoutNetworkCall() { + val exception = assertThrows(AuthenticationException::class.java) { + runTest { passwordlessClient.challengeWithEmail("user@example.com", "").await() } + } + assertThat(exception.getCode(), `is`("invalid_request")) + assertThat(exception.getDescription(), containsString("connection is required")) + assertThat(mockServer.requestCount, `is`(0)) + } + + @Test + public fun shouldFailChallengeWithBlankPhoneWithoutNetworkCall() { + val exception = assertThrows(AuthenticationException::class.java) { + runTest { passwordlessClient.challengeWithPhoneNumber("", CONNECTION).await() } + } + assertThat(exception.getCode(), `is`("invalid_request")) + assertThat(exception.getDescription(), containsString("phone_number is required")) + assertThat(mockServer.requestCount, `is`(0)) + } + + @Test + public fun shouldLoginWithOtpHitOauthTokenWithCorrectParams(): Unit = runTest { + enqueueMockResponse( + """{"access_token": "$ACCESS_TOKEN", "id_token": "$ID_TOKEN", "token_type": "Bearer", "expires_in": 86400}""" + ) + + passwordlessClient.loginWithOTP(PasswordlessChallenge("session_abc"), "123456").await() + + val request = mockServer.takeRequest() + assertThat(request.path, `is`("/oauth/token")) + assertThat(request.method, `is`("POST")) + val body = bodyFromRequest(request) + assertThat(body, hasEntry("client_id", CLIENT_ID)) + assertThat(body, hasEntry("grant_type", "http://auth0.com/oauth/grant-type/passwordless/otp")) + assertThat(body, hasEntry("auth_session", "session_abc")) + assertThat(body, hasEntry("otp", "123456")) + } + + @Test + public fun shouldLoginWithOtpReturnCredentials(): Unit = runTest { + enqueueMockResponse( + """{"access_token": "$ACCESS_TOKEN", "id_token": "$ID_TOKEN", "token_type": "Bearer", "expires_in": 86400}""" + ) + + val credentials = passwordlessClient.loginWithOTP(PasswordlessChallenge("session_abc"), "123456").await() + + assertThat(credentials.accessToken, `is`(ACCESS_TOKEN)) + } + + @Test + public fun shouldPropagateLoginWithOtpApiError() { + enqueueErrorResponse("invalid_grant", "Invalid or expired code", 403) + + val exception = assertThrows(AuthenticationException::class.java) { + runTest { passwordlessClient.loginWithOTP(PasswordlessChallenge("session_abc"), "000000").await() } + } + assertThat(exception.getCode(), `is`("invalid_grant")) + } + + @Test + public fun shouldFailLoginWithBlankOtpWithoutNetworkCall() { + val exception = assertThrows(AuthenticationException::class.java) { + runTest { passwordlessClient.loginWithOTP(PasswordlessChallenge("session_abc"), "").await() } + } + assertThat(exception.getCode(), `is`("invalid_request")) + assertThat(exception.getDescription(), containsString("otp is required")) + assertThat(mockServer.requestCount, `is`(0)) + } + + @Test + public fun shouldAttachDpopHeaderOnLoginWhenDpopEnabled(): Unit = runTest { + whenever(mockKeyStore.hasKeyPair()).thenReturn(true) + whenever(mockKeyStore.getKeyPair()).thenReturn(Pair(FakeECPrivateKey(), FakeECPublicKey())) + val dpopClient = AuthenticationAPIClient(auth0).useDPoP(mockContext).passwordlessClient() + enqueueMockResponse( + """{"access_token": "$ACCESS_TOKEN", "id_token": "$ID_TOKEN", "token_type": "Bearer", "expires_in": 86400}""" + ) + + dpopClient.loginWithOTP(PasswordlessChallenge("session_abc"), "123456").await() + + val request = mockServer.takeRequest() + assertThat(request.getHeader("DPoP"), `is`(notNullValue())) + } + + @Test + public fun shouldNotAttachDpopHeaderOnChallengeWhenDpopEnabled(): Unit = runTest { + whenever(mockKeyStore.hasKeyPair()).thenReturn(true) + whenever(mockKeyStore.getKeyPair()).thenReturn(Pair(FakeECPrivateKey(), FakeECPublicKey())) + val dpopClient = AuthenticationAPIClient(auth0).useDPoP(mockContext).passwordlessClient() + enqueueMockResponse("""{"auth_session": "session_abc"}""") + + dpopClient.challengeWithEmail("user@example.com", CONNECTION).await() + + val request = mockServer.takeRequest() + assertThat(request.getHeader("DPoP"), `is`(nullValue())) + } + + private companion object { + private const val CLIENT_ID = "CLIENT_ID" + private const val CONNECTION = "Username-Password-Authentication" + private const val ACCESS_TOKEN = "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0In0.sig" + private const val ID_TOKEN = "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0In0.sig" + } +} From 6a90fafc25f456f4473b9ad31819dff3821e534b Mon Sep 17 00:00:00 2001 From: utkrishtS Date: Fri, 26 Jun 2026 11:25:15 +0530 Subject: [PATCH 51/60] docs: clarify session_expiry units and how to emit the claim --- EXAMPLES.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/EXAMPLES.md b/EXAMPLES.md index b6a66aea6..d667ac4ee 100644 --- a/EXAMPLES.md +++ b/EXAMPLES.md @@ -3063,8 +3063,24 @@ The credentials managers enforce this ceiling automatically: - A small negative clock-skew leeway (~30 seconds) is applied, so the session is treated as expired slightly *before* the wall-clock ceiling, never after. - Connections that do not emit the claim are unaffected — there is no ceiling and behavior is unchanged. +> ⚠️ **The `session_expiry` value must be Unix seconds.** Per [RFC 7519](https://datatracker.ietf.org/doc/html/rfc7519), the claim is interpreted as seconds since the Unix epoch. A millisecond-magnitude value (e.g. `1700000000000`) resolves to a date ~50,000 years out and would **silently disable** the ceiling, so the SDK treats any implausibly large value (`>= 10_000_000_000`) as "no ceiling". The SDK also **fails open** on any malformed value — a non-numeric, zero, negative, or millisecond value is treated as "no ceiling" and the session proceeds without enforcement. When emitting the claim from an Action, always use seconds (divide a milliseconds timestamp by 1000). + > ⚠️ **Upgrade note:** For a user whose connection asserts `session_expiry`, a `getCredentials` call that previously succeeded can now fail with `SESSION_EXPIRED` once the ceiling is reached. Make sure your error handling treats `SESSION_EXPIRED` as a prompt to re-authenticate. +#### Emitting the claim + +The `session_expiry` claim is not emitted by default — it is set on your tenant by a [Post-Login Action](https://auth0.com/docs/customize/actions/flows-and-triggers/login-flow) that adds it to the ID token, for example: + +```javascript +exports.onExecutePostLogin = async (event, api) => { + // session_expiry must be expressed in Unix seconds + const sessionExpiry = Math.floor(Date.now() / 1000) + 8 * 60 * 60; // 8 hours from now + api.idToken.setCustomClaim('session_expiry', sessionExpiry); +}; +``` + +> 📝 A link to the canonical Auth0 `session_expiry` Action guide will be added here once it is published. + You can read the ceiling for a given credential set from `Credentials.sessionExpiresAt` (a nullable `Long` of Unix seconds, `null` when the connection does not emit the claim): ```kotlin From ebcf60ae104732d1a3ca3fac382f61bd32ee0b80 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 28 Jun 2026 16:56:45 +0530 Subject: [PATCH 52/60] chore(deps): bump actions/setup-java from 5.3.0 to 5.4.0 (#991) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/codeql.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 49f23397d..4ab088add 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -36,7 +36,7 @@ jobs: run: exit 0 # Skip unnecessary test runs for dependabot and merge queues. Artifically flag as successful, as this is a required check for branch protection. - name: Set up Java - uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5.3.0 + uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5.4.0 with: distribution: 'temurin' java-version: '11' From ac30598c200e0b7d85d97420225844446a79a91b Mon Sep 17 00:00:00 2001 From: Prince Mathew <17837162+pmathew92@users.noreply.github.com> Date: Mon, 29 Jun 2026 14:30:26 +0530 Subject: [PATCH 53/60] feat: Added DPoP support for MFA APIs (#992) --- EXAMPLES.md | 26 +++ .../authentication/AuthenticationAPIClient.kt | 5 +- .../authentication/mfa/MfaApiClient.kt | 35 +++- .../authentication/MfaApiClientTest.kt | 156 ++++++++++++++++++ 4 files changed, 213 insertions(+), 9 deletions(-) diff --git a/EXAMPLES.md b/EXAMPLES.md index d667ac4ee..6ad20c050 100644 --- a/EXAMPLES.md +++ b/EXAMPLES.md @@ -682,6 +682,32 @@ MfaApiClient mfaClient = authentication.mfaClient(mfaToken); ``` +##### Using DPoP with MFA + +If the originating `AuthenticationAPIClient` has [DPoP](#dpop) enabled, the resulting `mfaClient` inherits it automatically, and the final `verify()` call exchanging credentials at `/oauth/token` will carry a DPoP proof: + +```kotlin +val authentication = AuthenticationAPIClient(account).useDPoP(context) +val mfaClient = authentication.mfaClient(mfaToken) // DPoP inherited +``` + +Alternatively, if you are using the `MfaApiClient` on its own, enable DPoP directly on it: + +```kotlin +val mfaClient = MfaApiClient(account, mfaToken).useDPoP(context) +``` + +
+ Using Java + +```java +MfaApiClient mfaClient = new MfaApiClient(account, mfaToken).useDPoP(context); +``` +
+ +> [!NOTE] +> The proof is only attached to the token exchange performed by `verify()`. The `getAuthenticators()`, `enroll()`, and `challenge()` calls authenticate with the MFA token as a bearer credential and do not carry a DPoP proof. + #### Getting Available Authenticators Retrieve the list of authenticators that the user has enrolled and are allowed for this authentication flow. The `factorsAllowed` parameter filters the authenticators based on the allowed factor types from the MFA requirements. 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 b80f34873..6fbdbf842 100755 --- a/auth0/src/main/java/com/auth0/android/authentication/AuthenticationAPIClient.kt +++ b/auth0/src/main/java/com/auth0/android/authentication/AuthenticationAPIClient.kt @@ -113,10 +113,11 @@ public class AuthenticationAPIClient @VisibleForTesting(otherwise = VisibleForTe * ``` * * @param mfaToken The token received in the 'mfa_required' error from a login attempt. - * @return A new [MfaApiClient] instance configured for the transaction. + * @return A new [MfaApiClient] instance configured for the transaction. If this client has + * DPoP enabled via [useDPoP], the returned MFA client inherits that configuration. */ public fun mfaClient(mfaToken: String): MfaApiClient { - return MfaApiClient(this.auth0, mfaToken) + return MfaApiClient(this.auth0, mfaToken, gson, this.dPoP) } /** 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 d22503a63..5dced6da8 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 @@ -1,5 +1,6 @@ package com.auth0.android.authentication.mfa +import android.content.Context import androidx.annotation.VisibleForTesting import com.auth0.android.Auth0 import com.auth0.android.Auth0Exception @@ -8,6 +9,9 @@ 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.dpop.DPoP +import com.auth0.android.dpop.DPoPException +import com.auth0.android.dpop.SenderConstraining import com.auth0.android.request.ErrorAdapter import com.auth0.android.request.JsonAdapter import com.auth0.android.request.Request @@ -56,8 +60,19 @@ import java.io.Reader public class MfaApiClient @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) internal constructor( private val auth0: Auth0, private val mfaToken: String, - private val gson: Gson -) { + private val gson: Gson, + private var dPoP: DPoP? = null +) : SenderConstraining { + + /** + * Enable DPoP for this client. When enabled, the MFA verification request to + * `/oauth/token` will carry a DPoP proof, binding the issued tokens to a key pair + * held in the Android KeyStore. + */ + public override fun useDPoP(context: Context): MfaApiClient { + dPoP = DPoP(context) + return this + } // Specialized factories for MFA-specific errors private val listAuthenticatorsFactory: RequestFactory by lazy { @@ -477,7 +492,7 @@ public class MfaApiClient @VisibleForTesting(otherwise = VisibleForTesting.PRIVA Credentials::class.java, gson ) - return verifyFactory.post(url.toString(), credentialsAdapter) + return verifyFactory.post(url.toString(), credentialsAdapter, dPoP) .addParameters(parameters) } @@ -621,14 +636,20 @@ public class MfaApiClient @VisibleForTesting(otherwise = VisibleForTesting.PRIVA } override fun fromException(cause: Throwable): MfaVerifyException { - return if (isNetworkError(cause)) { - MfaVerifyException( + return when { + isNetworkError(cause) -> MfaVerifyException( code = "network_error", description = "Failed to execute the network request", cause = cause ) - } else { - MfaVerifyException( + + cause is DPoPException -> MfaVerifyException( + code = "dpop_error", + description = cause.message ?: "Error while attaching DPoP proof", + cause = cause + ) + + else -> MfaVerifyException( code = Auth0Exception.UNKNOWN_ERROR, description = cause.message ?: "Something went wrong", cause = cause 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 acd415cd1..681758dcc 100644 --- a/auth0/src/test/java/com/auth0/android/authentication/MfaApiClientTest.kt +++ b/auth0/src/test/java/com/auth0/android/authentication/MfaApiClientTest.kt @@ -1,5 +1,6 @@ package com.auth0.android.authentication +import android.content.Context import com.auth0.android.Auth0 import com.auth0.android.authentication.mfa.MfaApiClient import com.auth0.android.authentication.mfa.MfaEnrollmentType @@ -8,6 +9,11 @@ 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.dpop.DPoPException +import com.auth0.android.dpop.DPoPKeyStore +import com.auth0.android.dpop.DPoPUtil +import com.auth0.android.dpop.FakeECPrivateKey +import com.auth0.android.dpop.FakeECPublicKey import com.auth0.android.request.internal.ThreadSwitcherShadow import com.auth0.android.result.Authenticator import com.auth0.android.result.Challenge @@ -19,6 +25,8 @@ import com.auth0.android.util.SSLTestUtils import com.google.gson.Gson import com.google.gson.GsonBuilder import com.google.gson.reflect.TypeToken +import com.nhaarman.mockitokotlin2.mock +import com.nhaarman.mockitokotlin2.whenever import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.runTest import okhttp3.mockwebserver.MockResponse @@ -31,6 +39,7 @@ import org.hamcrest.Matchers.instanceOf import org.hamcrest.Matchers.`is` import org.hamcrest.Matchers.notNullValue import org.hamcrest.Matchers.nullValue +import org.hamcrest.Matchers.sameInstance import org.junit.After import org.junit.Assert.assertThrows import org.junit.Before @@ -49,6 +58,8 @@ public class MfaApiClientTest { private lateinit var auth0: Auth0 private lateinit var mfaClient: MfaApiClient private lateinit var gson: Gson + private lateinit var mockKeyStore: DPoPKeyStore + private lateinit var mockContext: Context @Before public fun setUp(): Unit { @@ -59,11 +70,16 @@ public class MfaApiClientTest { auth0.networkingClient = SSLTestUtils.testClient mfaClient = MfaApiClient(auth0, MFA_TOKEN) gson = GsonBuilder().serializeNulls().create() + mockKeyStore = mock() + mockContext = mock() + whenever(mockContext.applicationContext).thenReturn(mockContext) + DPoPUtil.keyStore = mockKeyStore } @After public fun tearDown(): Unit { mockServer.shutdown() + DPoPUtil.keyStore = DPoPKeyStore() } private fun enqueueMockResponse(json: String, statusCode: Int = 200): Unit { @@ -96,6 +112,146 @@ public class MfaApiClientTest { assertThat(client, `is`(notNullValue())) } + @Test + public fun shouldAttachDpopHeaderOnVerifyWhenDpopEnabledViaUseDPoP(): Unit = runTest { + whenever(mockKeyStore.hasKeyPair()).thenReturn(true) + whenever(mockKeyStore.getKeyPair()).thenReturn(Pair(FakeECPrivateKey(), FakeECPublicKey())) + val dpopClient = MfaApiClient(auth0, MFA_TOKEN).useDPoP(mockContext) + enqueueMockResponse( + """{"access_token": "$ACCESS_TOKEN", "id_token": "$ID_TOKEN", "token_type": "Bearer", "expires_in": 86400}""" + ) + + dpopClient.verify(MfaVerificationType.Otp("123456")).await() + + val request = mockServer.takeRequest() + assertThat(request.path, `is`("/oauth/token")) + assertThat(request.getHeader("DPoP"), `is`(notNullValue())) + } + + @Test + public fun shouldAttachDpopHeaderOnVerifyWhenDpopInheritedFromAuthClient(): Unit = runTest { + whenever(mockKeyStore.hasKeyPair()).thenReturn(true) + whenever(mockKeyStore.getKeyPair()).thenReturn(Pair(FakeECPrivateKey(), FakeECPublicKey())) + val dpopClient = AuthenticationAPIClient(auth0).useDPoP(mockContext).mfaClient(MFA_TOKEN) + enqueueMockResponse( + """{"access_token": "$ACCESS_TOKEN", "id_token": "$ID_TOKEN", "token_type": "Bearer", "expires_in": 86400}""" + ) + + dpopClient.verify(MfaVerificationType.Otp("123456")).await() + + val request = mockServer.takeRequest() + assertThat(request.path, `is`("/oauth/token")) + assertThat(request.getHeader("DPoP"), `is`(notNullValue())) + } + + @Test + public fun shouldNotAttachDpopHeaderOnVerifyWhenDpopDisabled(): Unit = runTest { + whenever(mockKeyStore.hasKeyPair()).thenReturn(false) + enqueueMockResponse( + """{"access_token": "$ACCESS_TOKEN", "id_token": "$ID_TOKEN", "token_type": "Bearer", "expires_in": 86400}""" + ) + + mfaClient.verify(MfaVerificationType.Otp("123456")).await() + + val request = mockServer.takeRequest() + assertThat(request.getHeader("DPoP"), `is`(nullValue())) + } + + @Test + public fun shouldNotAttachDpopHeaderOnChallengeWhenDpopEnabled(): Unit = runTest { + whenever(mockKeyStore.hasKeyPair()).thenReturn(true) + whenever(mockKeyStore.getKeyPair()).thenReturn(Pair(FakeECPrivateKey(), FakeECPublicKey())) + val dpopClient = MfaApiClient(auth0, MFA_TOKEN).useDPoP(mockContext) + enqueueMockResponse("""{"challenge_type": "oob", "oob_code": "oob_123"}""") + + dpopClient.challenge("sms|dev_123").await() + + val request = mockServer.takeRequest() + assertThat(request.path, `is`("/mfa/challenge")) + assertThat(request.getHeader("DPoP"), `is`(nullValue())) + } + + @Test + public fun shouldNotAttachDpopHeaderOnEnrollWhenDpopEnabled(): Unit = runTest { + whenever(mockKeyStore.hasKeyPair()).thenReturn(true) + whenever(mockKeyStore.getKeyPair()).thenReturn(Pair(FakeECPrivateKey(), FakeECPublicKey())) + val dpopClient = MfaApiClient(auth0, MFA_TOKEN).useDPoP(mockContext) + enqueueMockResponse("""{"id": "sms|dev_123", "auth_session": "session_abc"}""") + + dpopClient.enroll(MfaEnrollmentType.Phone("+12025550135")).await() + + val request = mockServer.takeRequest() + assertThat(request.path, `is`("/mfa/associate")) + assertThat(request.getHeader("DPoP"), `is`(nullValue())) + } + + @Test + public fun shouldNotAttachDpopHeaderOnGetAuthenticatorsWhenDpopEnabled(): Unit = runTest { + whenever(mockKeyStore.hasKeyPair()).thenReturn(true) + whenever(mockKeyStore.getKeyPair()).thenReturn(Pair(FakeECPrivateKey(), FakeECPublicKey())) + val dpopClient = MfaApiClient(auth0, MFA_TOKEN).useDPoP(mockContext) + enqueueMockResponse("""[{"id": "sms|dev_123", "type": "oob", "active": true}]""") + + dpopClient.getAuthenticators(listOf("oob")).await() + + val request = mockServer.takeRequest() + assertThat(request.path, `is`("/mfa/authenticators")) + assertThat(request.getHeader("DPoP"), `is`(nullValue())) + } + + @Test + public fun shouldWrapDPoPExceptionAsMfaVerifyException(): Unit { + whenever(mockKeyStore.hasKeyPair()).thenReturn(true) + whenever(mockKeyStore.getKeyPair()).thenThrow(DPoPException.KEY_PAIR_NOT_FOUND) + val dpopClient = MfaApiClient(auth0, MFA_TOKEN).useDPoP(mockContext) + + val exception = assertThrows(MfaVerifyException::class.java) { + runTest { + dpopClient.verify(MfaVerificationType.Otp("123456")).await() + } + } + assertThat(exception.getCode(), `is`("dpop_error")) + assertThat(mockServer.requestCount, `is`(0)) + } + + @Test + public fun shouldAttachDpopHeaderOnVerifyWhenDpopEnabledWithCallback(): Unit { + whenever(mockKeyStore.hasKeyPair()).thenReturn(true) + whenever(mockKeyStore.getKeyPair()).thenReturn(Pair(FakeECPrivateKey(), FakeECPublicKey())) + val dpopClient = MfaApiClient(auth0, MFA_TOKEN).useDPoP(mockContext) + enqueueMockResponse( + """{"access_token": "$ACCESS_TOKEN", "id_token": "$ID_TOKEN", "token_type": "Bearer", "expires_in": 86400}""" + ) + + val callback = MockCallback() + dpopClient.verify(MfaVerificationType.Otp("123456")).start(callback) + ShadowLooper.idleMainLooper() + + assertThat(callback.getPayload(), `is`(notNullValue())) + assertThat(callback.getPayload().accessToken, `is`(ACCESS_TOKEN)) + assertThat(callback.getError(), `is`(nullValue())) + + val request = mockServer.takeRequest() + assertThat(request.path, `is`("/oauth/token")) + assertThat(request.getHeader("DPoP"), `is`(notNullValue())) + } + + @Test + public fun shouldWrapDPoPExceptionAsMfaVerifyExceptionWithCallback(): Unit { + whenever(mockKeyStore.hasKeyPair()).thenReturn(true) + whenever(mockKeyStore.getKeyPair()).thenThrow(DPoPException.KEY_PAIR_NOT_FOUND) + val dpopClient = MfaApiClient(auth0, MFA_TOKEN).useDPoP(mockContext) + + val callback = MockCallback() + dpopClient.verify(MfaVerificationType.Otp("123456")).start(callback) + ShadowLooper.idleMainLooper() + + assertThat(callback.getPayload(), `is`(nullValue())) + assertThat(callback.getError(), `is`(notNullValue())) + assertThat(callback.getError().getCode(), `is`("dpop_error")) + assertThat(mockServer.requestCount, `is`(0)) + } + @Test public fun shouldIncludeAuth0ClientHeaderInGetAuthenticators(): Unit = runTest { From aa6581c88f1172280bd92a0f5a03c18597f2b79a Mon Sep 17 00:00:00 2001 From: Prince Mathew <17837162+pmathew92@users.noreply.github.com> Date: Tue, 30 Jun 2026 10:19:45 +0530 Subject: [PATCH 54/60] Release 3.20.0 (#993) --- .version | 2 +- CHANGELOG.md | 8 ++++++++ README.md | 2 +- 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/.version b/.version index 209f5795d..b1f5ebeda 100644 --- a/.version +++ b/.version @@ -1 +1 @@ -3.19.0 \ No newline at end of file +3.20.0 \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 49f93ed1f..1354692e5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Change Log +## [3.20.0](https://github.com/auth0/Auth0.Android/tree/3.20.0) (2026-06-29) +[Full Changelog](https://github.com/auth0/Auth0.Android/compare/3.19.0...3.20.0) + +**Added** +- feat: Added DPoP support for MFA APIs [\#992](https://github.com/auth0/Auth0.Android/pull/992) ([pmathew92](https://github.com/pmathew92)) +- feat: enforce IPSIE session_expiry ceiling in credentials managers [\#983](https://github.com/auth0/Auth0.Android/pull/983) ([utkrishtsahu](https://github.com/utkrishtsahu)) +- feat : Added passwordless API for DB-Connections [\#989](https://github.com/auth0/Auth0.Android/pull/989) ([pmathew92](https://github.com/pmathew92)) + ## [3.19.0](https://github.com/auth0/Auth0.Android/tree/3.19.0) (2026-06-15) [Full Changelog](https://github.com/auth0/Auth0.Android/compare/3.18.0...3.19.0) diff --git a/README.md b/README.md index e1bd8d20d..348158733 100644 --- a/README.md +++ b/README.md @@ -66,7 +66,7 @@ To install Auth0.Android with [Gradle](https://gradle.org/), simply add the foll ```gradle dependencies { - implementation 'com.auth0.android:auth0:3.19.0' + implementation 'com.auth0.android:auth0:3.20.0' } ``` From d72728347cb9315d70ad3747a77983709ed74a1c Mon Sep 17 00:00:00 2001 From: utkrishtS Date: Wed, 1 Jul 2026 15:48:12 +0530 Subject: [PATCH 55/60] fix(mfa): match getAuthenticators by Authenticator.type field --- EXAMPLES.md | 8 +- .../authentication/mfa/MfaApiClient.kt | 73 +++---------------- .../authentication/MfaApiClientTest.kt | 2 +- 3 files changed, 16 insertions(+), 67 deletions(-) diff --git a/EXAMPLES.md b/EXAMPLES.md index 6ad20c050..265c800e8 100644 --- a/EXAMPLES.md +++ b/EXAMPLES.md @@ -560,7 +560,7 @@ authentication requirements?.enroll?.let { enrollTypes -> println("User needs to enroll MFA") println("Available enrollment types: ${enrollTypes.map { it.type }}") - // Example output: ["otp", "sms", "push-notification"] + // Example output: ["otp", "phone", "push-notification"] // Proceed with MFA enrollment using one of these types } @@ -568,7 +568,7 @@ authentication requirements?.challenge?.let { challengeTypes -> println("User has enrolled MFA factors") println("Available challenge types: ${challengeTypes.map { it.type }}") - // Example output: ["otp", "sms"] + // Example output: ["otp", "phone"] // Get authenticators and challenge one of them } @@ -650,14 +650,14 @@ try { requirements?.enroll?.let { enrollTypes -> println("User needs to enroll MFA") println("Available enrollment types: ${enrollTypes.map { it.type }}") - // Example output: ["otp", "sms", "push-notification"] + // Example output: ["otp", "phone", "push-notification"] } // Check if challenge is available requirements?.challenge?.let { challengeTypes -> println("User has enrolled MFA factors") println("Available challenge types: ${challengeTypes.map { it.type }}") - // Example output: ["otp", "sms"] + // Example output: ["otp", "phone"] } // Proceed with MFA flow using mfaToken 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 5dced6da8..0a1e6566b 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 @@ -124,16 +124,19 @@ public class MfaApiClient @VisibleForTesting(otherwise = VisibleForTesting.PRIVA * ## Usage * * ```kotlin - * mfaClient.getAuthenticators(listOf("otp", "oob")) + * mfaClient.getAuthenticators(listOf("otp", "phone")) * .start(object : Callback, MfaListAuthenticatorsException> { * override fun onSuccess(result: List) { - * // Only OTP and OOB authenticators returned + * // Only authenticators whose `type` is "otp" or "phone" are returned * } * override fun onFailure(error: MfaListAuthenticatorsException) { } * }) * ``` * - * @param factorsAllowed Array of factor types to filter the authenticators (e.g., `["otp", "oob", "recovery-code"]`). + * Filtering matches each authenticator's [Authenticator.type] field against the + * provided values using exact (case-sensitive) equality. + * + * @param factorsAllowed Array of factor types to filter the authenticators (e.g., `["otp", "phone", "email", "recovery-code"]`). * Must contain at least one factor type. * @return a request to configure and start that will yield a list of [Authenticator] * @@ -307,11 +310,11 @@ public class MfaApiClient @VisibleForTesting(otherwise = VisibleForTesting.PRIVA * transparently by the SDK. * * **Filtering:** - * Authenticators are filtered by their effective type: - * - OOB authenticators: matched by their channel ("sms" or "email") - * - Other authenticators: matched by their type ("otp", "recovery-code", etc.) + * An authenticator is included when [Authenticator.type] exactly matches one of the + * provided [factorsAllowed] values (case-sensitive equality). This mirrors the + * filtering behavior of the Auth0.swift SDK. * - * @param factorsAllowed List of factor types to include (e.g., ["sms", "email", "otp"]) + * @param factorsAllowed List of factor types to include (e.g., ["phone", "email", "otp"]) * @return A JsonAdapter that produces a filtered list of authenticators */ private fun createFilteringAuthenticatorsAdapter(factorsAllowed: List): JsonAdapter> { @@ -321,66 +324,12 @@ public class MfaApiClient @VisibleForTesting(otherwise = VisibleForTesting.PRIVA val allAuthenticators = baseAdapter.fromJson(reader, metadata) return allAuthenticators.filter { authenticator -> - matchesFactorType(authenticator, factorsAllowed) + factorsAllowed.contains(authenticator.type) } } } } - /** - * Checks if an authenticator matches any of the allowed factor types. - * - * The matching logic handles various factor type aliases: - * - "sms" or "phone": matches OOB authenticators with SMS channel - * - "email": matches OOB authenticators with email channel - * - "otp" or "totp": matches time-based one-time password authenticators - * - "oob": matches any out-of-band authenticator regardless of channel - * - "recovery-code": matches recovery code authenticators - * - "push-notification": matches push notification authenticators - * - * @param authenticator The authenticator to check - * @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 { - val effectiveType = getEffectiveType(authenticator) - - return factorsAllowed.any { factor -> - val normalizedFactor = factor.lowercase(java.util.Locale.ROOT) - when (normalizedFactor) { - "sms", "phone" -> effectiveType == "sms" || effectiveType == "phone" - "email" -> effectiveType == "email" - "otp", "totp" -> effectiveType == "otp" || effectiveType == "totp" - "oob" -> authenticator.authenticatorType == "oob" || authenticator.type == "oob" - "recovery-code" -> effectiveType == "recovery-code" - "push-notification" -> effectiveType == "push-notification" - else -> effectiveType == normalizedFactor || - authenticator.authenticatorType?.lowercase(java.util.Locale.ROOT) == normalizedFactor || - authenticator.type.lowercase(java.util.Locale.ROOT) == normalizedFactor - } - } - } - - /** - * Resolves the effective type of an authenticator for filtering purposes. - * - * OOB (out-of-band) authenticators use their channel ("sms" or "email") as the - * effective type, since users typically filter by delivery method rather than - * the generic "oob" type. Other authenticators use their authenticatorType directly. - * - * @param authenticator The authenticator to get the type for - * @return The effective type string used for filtering - */ - private fun getEffectiveType(authenticator: Authenticator): String { - return when (authenticator.authenticatorType) { - "oob" -> authenticator.oobChannel ?: "oob" - else -> authenticator.authenticatorType ?: authenticator.type - } - } - /** * Helper function for OOB enrollment (SMS, email, push). */ 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 681758dcc..47476a993 100644 --- a/auth0/src/test/java/com/auth0/android/authentication/MfaApiClientTest.kt +++ b/auth0/src/test/java/com/auth0/android/authentication/MfaApiClientTest.kt @@ -839,7 +839,7 @@ public class MfaApiClientTest { @Test public fun shouldGetAuthenticatorsWithCallback(): Unit { - val json = """[{"id": "sms|dev_123", "authenticator_type": "oob", "active": true}]""" + val json = """[{"id": "sms|dev_123", "type": "oob", "authenticator_type": "oob", "active": true}]""" enqueueMockResponse(json) val callback = MockCallback, MfaListAuthenticatorsException>() From 89a3d70c60b5353a8349cda5333110ded03b7096 Mon Sep 17 00:00:00 2001 From: Prince Mathew <17837162+pmathew92@users.noreply.github.com> Date: Tue, 7 Jul 2026 12:41:45 +0530 Subject: [PATCH 56/60] feat: Add password enrollment to MyAccount API (#1003) --- EXAMPLES.md | 64 +++++++++ .../android/myaccount/MyAccountAPIClient.kt | 97 ++++++++++++++ .../android/result/EnrollmentChallenge.kt | 16 ++- .../auth0/android/result/PasswordPolicy.kt | 122 ++++++++++++++++++ .../myaccount/MyAccountAPIClientTest.kt | 97 ++++++++++++++ .../android/util/MyAccountAPIMockServer.kt | 50 +++++++ 6 files changed, 445 insertions(+), 1 deletion(-) create mode 100644 auth0/src/main/java/com/auth0/android/result/PasswordPolicy.kt diff --git a/EXAMPLES.md b/EXAMPLES.md index 6ad20c050..442214f88 100644 --- a/EXAMPLES.md +++ b/EXAMPLES.md @@ -47,6 +47,7 @@ - [Enroll a TOTP (Authenticator App) Method](#enroll-a-totp-authenticator-app-method) - [Enroll a Push Notification Method](#enroll-a-push-notification-method) - [Enroll a Recovery Code](#enroll-a-recovery-code) + - [Enroll a Password Method](#enroll-a-password-method) - [Verify an Enrollment](#verify-an-enrollment) - [Delete an Authentication Method](#delete-an-authentication-method) - [Update an Authentication Method](#update-an-authentication-method) @@ -2595,6 +2596,69 @@ myAccountClient.enrollRecoveryCode() ``` +### Enroll a Password Method +**Scopes required:** `create:me:authentication_methods` + +Enrolling a password authentication method is a two-step process. First, you request an enrollment challenge, which returns the connection's password [policy](https://auth0.com/docs/authenticate/database-connections/password-options) so you can guide the user to choose a compliant password. Then, you confirm the enrollment with the new password. + +#### 1. Request an enrollment challenge + +```kotlin +myAccountClient.enrollPassword() + .start(object : Callback { + override fun onSuccess(result: PasswordEnrollmentChallenge) { + // Use result.policy to validate the user's new password before confirming. + // Then use result.id and result.authSession to verify. + } + override fun onFailure(error: MyAccountException) { } + }) +``` + +
+ Using Java + +```java +myAccountClient.enrollPassword() + .start(new Callback() { + @Override + public void onSuccess(PasswordEnrollmentChallenge result) { + // Use result.getPolicy() to validate the user's new password before confirming. + // Then use result.getId() and result.getAuthSession() to verify. + } + @Override + public void onFailure(@NonNull MyAccountException error) { } + }); +``` +
+ +#### 2. Confirm the enrollment + +```kotlin +myAccountClient.verifyPassword("challenge_id_from_enroll", "auth_session_from_enroll", "new_password") + .start(object : Callback { + override fun onSuccess(result: PasswordAuthenticationMethod) { + // Enrollment successful + } + override fun onFailure(error: MyAccountException) { } + }) +``` + +
+ Using Java + +```java +myAccountClient.verifyPassword("challenge_id_from_enroll", "auth_session_from_enroll", "new_password") + .start(new Callback() { + @Override + public void onSuccess(PasswordAuthenticationMethod result) { + // Enrollment successful + } + @Override + public void onFailure(@NonNull MyAccountException error) { } + }); +``` +
+ ### Verify an Enrollment **Scopes required:** `create:me:authentication_methods` 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 994616162..f8acc933e 100644 --- a/auth0/src/main/java/com/auth0/android/myaccount/MyAccountAPIClient.kt +++ b/auth0/src/main/java/com/auth0/android/myaccount/MyAccountAPIClient.kt @@ -24,7 +24,9 @@ import com.auth0.android.result.Factor import com.auth0.android.result.Factors import com.auth0.android.result.PasskeyAuthenticationMethod import com.auth0.android.result.PasskeyEnrollmentChallenge +import com.auth0.android.result.PasswordAuthenticationMethod import com.auth0.android.result.PasskeyRegistrationChallenge +import com.auth0.android.result.PasswordEnrollmentChallenge import com.auth0.android.result.RecoveryCodeEnrollmentChallenge import com.auth0.android.result.TotpEnrollmentChallenge import com.google.gson.Gson @@ -743,6 +745,100 @@ public class MyAccountAPIClient @VisibleForTesting(otherwise = VisibleForTesting .addHeader(AUTHORIZATION_KEY, authorizationHeader) } + /** + * Starts the enrollment of a password authentication method. This is the first part of a + * two-step flow: the returned challenge carries the [com.auth0.android.result.PasswordPolicy] + * the new password must satisfy, along with the `id` and `authSession` needed to confirm the + * enrollment via [verifyPassword]. + * + * ## Scopes Required + * `create:me:authentication_methods` + * + * ## Usage + * + * ```kotlin + * val auth0 = Auth0.getInstance("YOUR_CLIENT_ID", "YOUR_DOMAIN") + * val apiClient = MyAccountAPIClient(auth0, accessToken) + * + * apiClient.enrollPassword() + * .start(object : Callback { + * override fun onSuccess(result: PasswordEnrollmentChallenge) { + * // Use result.policy to guide the user, then call verifyPassword(...) + * } + * override fun onFailure(error: MyAccountException) { //... } + * }) + * ``` + * @param userIdentity Unique identifier of the current user's identity. Needed if the user logged in with a [linked account](https://auth0.com/docs/manage-users/user-accounts/user-account-linking) + * @param connection Name of the database connection where the user is stored + * @return a request that will yield a password enrollment challenge. + */ + @JvmOverloads + public fun enrollPassword( + userIdentity: String? = null, + connection: String? = null + ): Request { + val url = getDomainUrlBuilder().addPathSegment(AUTHENTICATION_METHODS).build() + val params = ParameterBuilder.newBuilder().apply { + set(TYPE_KEY, AuthenticationMethodType.PASSWORD.type) + userIdentity?.let { set(USER_IDENTITY_ID_KEY, it) } + connection?.let { set(CONNECTION_KEY, it) } + }.asDictionary() + return factory.post( + url.toString(), + GsonAdapter(PasswordEnrollmentChallenge::class.java, gson), + dPoP + ) + .addParameters(params) + .addHeader(AUTHORIZATION_KEY, authorizationHeader) + } + + /** + * Confirms the enrollment of a password method by providing the new password. + * + * ## Scopes Required + * `create:me:authentication_methods` + * + * ## Usage + * + * ```kotlin + * val auth0 = Auth0.getInstance("YOUR_CLIENT_ID", "YOUR_DOMAIN") + * val apiClient = MyAccountAPIClient(auth0, accessToken) + * + * val authMethodId = "from_enrollment_challenge" + * val authSession = "from_enrollment_challenge" + * val newPassword = "the_users_new_password" + * + * apiClient.verifyPassword(authMethodId, authSession, newPassword) + * .start(object : Callback { + * override fun onSuccess(result: PasswordAuthenticationMethod) { //... } + * override fun onFailure(error: MyAccountException) { //... } + * }) + * ``` + * @param authenticationMethodId The ID of the method being verified (from the enrollment challenge). + * @param authSession The auth session from the enrollment challenge. + * @param newPassword The new password to set, satisfying the policy from the enrollment challenge. + * @return a request that will yield the newly verified password authentication method. + */ + public fun verifyPassword( + authenticationMethodId: String, + authSession: String, + newPassword: String + ): Request { + val url = getDomainUrlBuilder() + .addPathSegment(AUTHENTICATION_METHODS) + .addPathSegment(authenticationMethodId) + .addPathSegment(VERIFY) + .build() + val params = mapOf(NEW_PASSWORD_KEY to newPassword, AUTH_SESSION_KEY to authSession) + return factory.post( + url.toString(), + GsonAdapter(PasswordAuthenticationMethod::class.java, gson), + dPoP + ) + .addParameters(params) + .addHeader(AUTHORIZATION_KEY, authorizationHeader) + } + // WebAuthn methods are private. /** * Starts the enrollment of a WebAuthn Platform (e.g., biometrics) authenticator. @@ -824,6 +920,7 @@ public class MyAccountAPIClient @VisibleForTesting(otherwise = VisibleForTesting private const val AUTHORIZATION_KEY = "Authorization" private const val LOCATION_KEY = "location" private const val AUTH_SESSION_KEY = "auth_session" + private const val NEW_PASSWORD_KEY = "new_password" private const val AUTHN_RESPONSE_KEY = "authn_response" private const val PREFERRED_AUTHENTICATION_METHOD = "preferred_authentication_method" private const val AUTHENTICATION_METHOD_NAME = "name" diff --git a/auth0/src/main/java/com/auth0/android/result/EnrollmentChallenge.kt b/auth0/src/main/java/com/auth0/android/result/EnrollmentChallenge.kt index 5357a3fc0..ecc4f7016 100644 --- a/auth0/src/main/java/com/auth0/android/result/EnrollmentChallenge.kt +++ b/auth0/src/main/java/com/auth0/android/result/EnrollmentChallenge.kt @@ -24,7 +24,8 @@ public sealed class EnrollmentChallenge { jsonObject.has("barcode_uri") -> TotpEnrollmentChallenge::class.java jsonObject.has("recovery_code") -> RecoveryCodeEnrollmentChallenge::class.java jsonObject.has("authn_params_public_key") -> PasskeyEnrollmentChallenge::class.java - jsonObject.has("oob_code") -> OobEnrollmentChallenge::class.java + jsonObject.has("oob_code") -> OobEnrollmentChallenge::class.java + jsonObject.has("policy") -> PasswordEnrollmentChallenge::class.java else -> MfaEnrollmentChallenge::class.java } return context.deserialize(jsonObject, targetClass) @@ -72,4 +73,17 @@ public data class RecoveryCodeEnrollmentChallenge( override val authSession: String, @SerializedName("recovery_code") public val recoveryCode: String +) : EnrollmentChallenge() + +/** + * Enrollment challenge for a password authentication method. Includes the [policy] the new password + * must satisfy, so the app can guide the user before confirming the enrollment. + */ +public data class PasswordEnrollmentChallenge( + @SerializedName("id") + override val id: String, + @SerializedName("auth_session") + override val authSession: String, + @SerializedName("policy") + public val policy: PasswordPolicy ) : EnrollmentChallenge() \ No newline at end of file diff --git a/auth0/src/main/java/com/auth0/android/result/PasswordPolicy.kt b/auth0/src/main/java/com/auth0/android/result/PasswordPolicy.kt new file mode 100644 index 000000000..e1bb22ae0 --- /dev/null +++ b/auth0/src/main/java/com/auth0/android/result/PasswordPolicy.kt @@ -0,0 +1,122 @@ +package com.auth0.android.result + +import com.google.gson.annotations.SerializedName + +/** + * Describes the password policy that a new password must satisfy, as returned by the My Account API + * when starting a password enrollment. Use it to build a UI that guides the user toward a compliant + * password. + */ +public data class PasswordPolicy( + /** + * Rules governing the structural complexity of the password (length, character types, etc.). + */ + @SerializedName("complexity") + public val complexity: PasswordComplexity, + /** + * Rules that prevent the password from containing personal information taken from the user profile. + */ + @SerializedName("profile_data") + public val profileData: PasswordProfileData, + /** + * Rules that prevent reuse of previously used passwords. + */ + @SerializedName("history") + public val history: PasswordHistory, + /** + * Rules that prevent the use of common dictionary words as passwords. + */ + @SerializedName("dictionary") + public val dictionary: PasswordDictionary +) + +/** + * Structural complexity requirements for a password. + */ +public data class PasswordComplexity( + /** + * The minimum number of characters the password must contain. + */ + @SerializedName("min_length") + public val minLength: Int?, + /** + * The character classes the password may be required to include. + * Possible values: `uppercase`, `lowercase`, `number`, `special`. + */ + @SerializedName("character_types") + public val characterTypes: List?, + /** + * How the [characterTypes] requirement is enforced. + * Possible values: `all` (every listed type is required), `three_of_four`. + */ + @SerializedName("character_type_rule") + public val characterTypeRule: String?, + /** + * Whether identical consecutive characters are permitted. + * Possible values: `allow`, `block`. + */ + @SerializedName("identical_characters") + public val identicalCharacters: String?, + /** + * Whether sequential characters (e.g. `abc`, `123`) are permitted. + * Possible values: `allow`, `block`. + */ + @SerializedName("sequential_characters") + public val sequentialCharacters: String?, + /** + * How a password that exceeds the maximum allowed length is handled. + * Possible values: `truncate`, `error`. + */ + @SerializedName("max_length_exceeded") + public val maxLengthExceeded: String? +) + +/** + * Rules that block the use of personal information from the user profile within a password. + */ +public data class PasswordProfileData( + /** + * Whether blocking of personal information is enabled. + */ + @SerializedName("active") + public val active: Boolean?, + /** + * The user profile fields whose values must not appear in the password + * (e.g. `name`, `email`, `user_metadata.first`). + */ + @SerializedName("blocked_fields") + public val blockedFields: List? +) + +/** + * Rules that prevent reuse of previously used passwords. + */ +public data class PasswordHistory( + /** + * Whether password history enforcement is enabled. + */ + @SerializedName("active") + public val active: Boolean?, + /** + * The number of previous passwords that cannot be reused. + */ + @SerializedName("size") + public val size: Int? +) + +/** + * Rules that prevent the use of common dictionary words as passwords. + */ +public data class PasswordDictionary( + /** + * Whether dictionary checking is enabled. + */ + @SerializedName("active") + public val active: Boolean?, + /** + * The default dictionary used for the check. + * Possible values: `en_10k`, `en_100k`. + */ + @SerializedName("default") + public val default: String? +) 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 76a095428..5e60b18c7 100644 --- a/auth0/src/test/java/com/auth0/android/myaccount/MyAccountAPIClientTest.kt +++ b/auth0/src/test/java/com/auth0/android/myaccount/MyAccountAPIClientTest.kt @@ -13,6 +13,8 @@ import com.auth0.android.result.EnrollmentChallenge import com.auth0.android.result.Factor import com.auth0.android.result.PasskeyAuthenticationMethod import com.auth0.android.result.PasskeyEnrollmentChallenge +import com.auth0.android.result.PasswordAuthenticationMethod +import com.auth0.android.result.PasswordEnrollmentChallenge import com.auth0.android.result.RecoveryCodeEnrollmentChallenge import com.auth0.android.result.TotpEnrollmentChallenge import com.auth0.android.util.AuthenticationAPIMockServer.Companion.SESSION_ID @@ -544,6 +546,101 @@ public class MyAccountAPIClientTest { assertThat(body, Matchers.hasEntry("type", "push-notification" as Any)) } + @Test + public fun `enrollPassword should send correct payload`() { + val callback = MockMyAccountCallback() + client.enrollPassword().start(callback) + + val request = mockAPI.takeRequest() + val body = bodyFromRequest(request) + assertThat(request.path, Matchers.equalTo("/me/v1/authentication-methods")) + assertThat(request.method, Matchers.equalTo("POST")) + assertThat(body, Matchers.hasEntry("type", "password" as Any)) + } + + @Test + public fun `enrollPassword should include userIdentity and connection parameters`() { + val callback = MockMyAccountCallback() + client.enrollPassword(USER_IDENTITY, CONNECTION).start(callback) + + val request = mockAPI.takeRequest() + val body = bodyFromRequest(request) + assertThat(body, Matchers.hasEntry("type", "password" as Any)) + assertThat(body, Matchers.hasEntry("identity_user_id", USER_IDENTITY as Any)) + assertThat(body, Matchers.hasEntry("connection", CONNECTION as Any)) + } + + @Test + public fun `enrollPassword should parse challenge with policy`() { + mockAPI.willReturnPasswordEnrollmentChallenge() + val challenge = client.enrollPassword().execute() + mockAPI.takeRequest() + + assertThat(challenge, Matchers.instanceOf(PasswordEnrollmentChallenge::class.java)) + assertThat(challenge.id, Matchers.equalTo("password|new")) + assertThat(challenge.authSession, Matchers.equalTo("SESSION_ID")) + assertThat(challenge.policy.complexity.minLength, Matchers.equalTo(8)) + assertThat( + challenge.policy.complexity.characterTypes, + Matchers.contains("uppercase", "lowercase", "number", "special") + ) + assertThat( + challenge.policy.complexity.characterTypeRule, + Matchers.equalTo("three_of_four") + ) + assertThat(challenge.policy.profileData.active, Matchers.equalTo(true)) + assertThat(challenge.policy.profileData.blockedFields, Matchers.contains("name", "email")) + assertThat(challenge.policy.history.size, Matchers.equalTo(5)) + assertThat(challenge.policy.dictionary.default, Matchers.equalTo("en_10k")) + } + + @Test + public fun `verifyPassword should send correct payload`() { + val callback = MockMyAccountCallback() + val methodId = "password|new" + val newPassword = "S3cr3tP@ssw0rd" + val session = "abc-def" + client.verifyPassword(methodId, session, newPassword).start(callback) + + val request = mockAPI.takeRequest() + val body = bodyFromRequest(request) + assertThat( + request.path, + Matchers.equalTo("/me/v1/authentication-methods/password%7Cnew/verify") + ) + assertThat(request.method, Matchers.equalTo("POST")) + assertThat(body, Matchers.hasEntry("new_password", newPassword as Any)) + assertThat(body, Matchers.hasEntry("auth_session", session as Any)) + } + + @Test + public fun `verifyPassword should return PasswordAuthenticationMethod on success`() { + mockAPI.willReturnPasswordAuthenticationMethod() + val response = client.verifyPassword("password|new", AUTH_SESSION, "S3cr3tP@ssw0rd") + .execute() + mockAPI.takeRequest() + + assertThat(response, Matchers.instanceOf(PasswordAuthenticationMethod::class.java)) + assertThat(response.id, Matchers.equalTo("password|pwd_a1b2c3d4e5f6")) + assertThat(response.type, Matchers.equalTo("password")) + assertThat(response.identityUserId, Matchers.equalTo("user_98765432")) + } + + @Test + public fun `enrollPassword should include DPoP proof header on POST when DPoP is enabled`() { + whenever(mockKeyStore.hasKeyPair()).thenReturn(true) + whenever(mockKeyStore.getKeyPair()).thenReturn(Pair(FakeECPrivateKey(), FakeECPublicKey())) + + val dpopClient = MyAccountAPIClient(auth0, ACCESS_TOKEN).useDPoP(mockContext) + val callback = MockMyAccountCallback() + dpopClient.enrollPassword().start(callback) + + val request = mockAPI.takeRequest() + assertThat(request.getHeader("Authorization"), Matchers.equalTo("DPoP $ACCESS_TOKEN")) + assertThat(request.getHeader("DPoP"), Matchers.notNullValue()) + assertThat(request.method, Matchers.equalTo("POST")) + } + // DPoP tests @Test diff --git a/auth0/src/test/java/com/auth0/android/util/MyAccountAPIMockServer.kt b/auth0/src/test/java/com/auth0/android/util/MyAccountAPIMockServer.kt index 53cdba370..d4905cb41 100644 --- a/auth0/src/test/java/com/auth0/android/util/MyAccountAPIMockServer.kt +++ b/auth0/src/test/java/com/auth0/android/util/MyAccountAPIMockServer.kt @@ -96,6 +96,54 @@ internal class MyAccountAPIMockServer : APIMockServer() { return this } + fun willReturnPasswordEnrollmentChallenge(): MyAccountAPIMockServer { + val json = """ + { + "id": "$PASSWORD_METHOD_ID", + "auth_session": "$SESSION_ID", + "policy": { + "complexity": { + "min_length": 8, + "character_types": ["uppercase", "lowercase", "number", "special"], + "character_type_rule": "three_of_four", + "identical_characters": "block", + "sequential_characters": "block", + "max_length_exceeded": "error" + }, + "profile_data": { + "active": true, + "blocked_fields": ["name", "email"] + }, + "history": { + "active": true, + "size": 5 + }, + "dictionary": { + "active": true, + "default": "en_10k" + } + } + } + """.trimIndent() + server.enqueue(responseWithJSON(json, 202)) + return this + } + + fun willReturnPasswordAuthenticationMethod(): MyAccountAPIMockServer { + val json = """ + { + "id": "$PASSWORD_VERIFIED_METHOD_ID", + "type": "password", + "created_at": "2023-06-15T14:30:25.000Z", + "usage": ["primary"], + "identity_user_id": "user_98765432", + "last_password_reset": "2023-06-15T14:30:25.000Z" + } + """.trimIndent() + server.enqueue(responseWithJSON(json, 201)) + return this + } + fun willReturnErrorForBadRequest(): MyAccountAPIMockServer { val responseBody = """ { @@ -148,5 +196,7 @@ internal class MyAccountAPIMockServer : APIMockServer() { private companion object { private const val SESSION_ID = "SESSION_ID" private const val CHALLENGE = "CHALLENGE" + private const val PASSWORD_METHOD_ID = "password|new" + private const val PASSWORD_VERIFIED_METHOD_ID = "password|pwd_a1b2c3d4e5f6" } } \ No newline at end of file From 33509c7ad32ea4849898c600101e88daaca88316 Mon Sep 17 00:00:00 2001 From: utkrishtS Date: Wed, 8 Jul 2026 10:21:08 +0530 Subject: [PATCH 57/60] fix(mfa): map TOTP enroll response fields for /mfa/associate --- EXAMPLES.md | 14 ++-- .../android/result/EnrollmentChallenge.kt | 27 +++++++- .../authentication/MfaApiClientTest.kt | 66 +++++++++++-------- 3 files changed, 72 insertions(+), 35 deletions(-) diff --git a/EXAMPLES.md b/EXAMPLES.md index 265c800e8..bb12642bc 100644 --- a/EXAMPLES.md +++ b/EXAMPLES.md @@ -868,10 +868,13 @@ mfaClient override fun onFailure(exception: MfaEnrollmentException) { } override fun onSuccess(enrollment: EnrollmentChallenge) { - // Display QR code or secret for user to scan/enter in authenticator app + // Display QR code or secret for user to scan/enter in authenticator app. + // The `/mfa/associate` endpoint returns the manual-entry key as `secret` + // (not `manualInputCode`, which is only populated by the My Account API). if (enrollment is TotpEnrollmentChallenge) { - val secret = enrollment.manualInputCode + val secret = enrollment.secret val barcodeUri = enrollment.barcodeUri + val recoveryCodes = enrollment.recoveryCodes } } }) @@ -889,11 +892,14 @@ mfaClient @Override public void onSuccess(EnrollmentChallenge enrollment) { - // Display QR code or secret for user to scan/enter in authenticator app + // Display QR code or secret for user to scan/enter in authenticator app. + // The `/mfa/associate` endpoint returns the manual-entry key as `secret` + // (not `manualInputCode`, which is only populated by the My Account API). if (enrollment instanceof TotpEnrollmentChallenge) { TotpEnrollmentChallenge totpEnrollment = (TotpEnrollmentChallenge) enrollment; - String secret = totpEnrollment.getManualInputCode(); + String secret = totpEnrollment.getSecret(); String barcodeUri = totpEnrollment.getBarcodeUri(); + List recoveryCodes = totpEnrollment.getRecoveryCodes(); } } }); diff --git a/auth0/src/main/java/com/auth0/android/result/EnrollmentChallenge.kt b/auth0/src/main/java/com/auth0/android/result/EnrollmentChallenge.kt index 5357a3fc0..25c46eb38 100644 --- a/auth0/src/main/java/com/auth0/android/result/EnrollmentChallenge.kt +++ b/auth0/src/main/java/com/auth0/android/result/EnrollmentChallenge.kt @@ -54,15 +54,36 @@ public data class OobEnrollmentChallenge( public val bindingMethod: String? = null ) : EnrollmentChallenge() +/** + * Enrollment challenge for TOTP (authenticator app) and Push factors, returned by both + * the MFA `/mfa/associate` endpoint and the My Account `/authentication-methods` endpoint. + * + * The two endpoints return different field sets, so every field except [barcodeUri] is + * optional: + * - `/mfa/associate` (ROPG MFA flow) returns [authenticatorType], [secret], [barcodeUri] + * and [recoveryCodes]. It does NOT return `id`, `auth_session` or `manual_input_code`. + * - `/authentication-methods` (My Account) returns [id], [authSession], [barcodeUri] and + * [manualInputCode]. + * + * [secret] and [manualInputCode] both carry the human-readable key for manual entry into an + * authenticator app; only one is populated depending on the endpoint. The [barcodeUri] + * (`otpauth://` URI) is always present and embeds the same secret. + */ public data class TotpEnrollmentChallenge( @SerializedName("id") - override val id: String, + override val id: String? = null, @SerializedName("auth_session") - override val authSession: String, + override val authSession: String? = null, @SerializedName("barcode_uri") public val barcodeUri: String, @SerializedName("manual_input_code") - public val manualInputCode: String? + public val manualInputCode: String? = null, + @SerializedName("authenticator_type") + public val authenticatorType: String? = null, + @SerializedName("secret") + public val secret: String? = null, + @SerializedName("recovery_codes") + public val recoveryCodes: List? = null ) : EnrollmentChallenge() public data class RecoveryCodeEnrollmentChallenge( 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 47476a993..1a9753927 100644 --- a/auth0/src/test/java/com/auth0/android/authentication/MfaApiClientTest.kt +++ b/auth0/src/test/java/com/auth0/android/authentication/MfaApiClientTest.kt @@ -190,9 +190,9 @@ public class MfaApiClientTest { whenever(mockKeyStore.hasKeyPair()).thenReturn(true) whenever(mockKeyStore.getKeyPair()).thenReturn(Pair(FakeECPrivateKey(), FakeECPublicKey())) val dpopClient = MfaApiClient(auth0, MFA_TOKEN).useDPoP(mockContext) - enqueueMockResponse("""[{"id": "sms|dev_123", "type": "oob", "active": true}]""") + enqueueMockResponse("""[{"id": "sms|dev_123", "type": "phone", "authenticator_type": "oob", "oob_channel": "sms", "active": true}]""") - dpopClient.getAuthenticators(listOf("oob")).await() + dpopClient.getAuthenticators(listOf("phone")).await() val request = mockServer.takeRequest() assertThat(request.path, `is`("/mfa/authenticators")) @@ -255,10 +255,10 @@ public class MfaApiClientTest { @Test public fun shouldIncludeAuth0ClientHeaderInGetAuthenticators(): Unit = runTest { - val json = """[{"id": "sms|dev_123", "type": "oob", "active": true}]""" + val json = """[{"id": "sms|dev_123", "type": "phone", "authenticator_type": "oob", "oob_channel": "sms", "active": true}]""" enqueueMockResponse(json) - mfaClient.getAuthenticators(listOf("oob")).await() + mfaClient.getAuthenticators(listOf("phone")).await() val request = mockServer.takeRequest() assertThat(request.getHeader("Auth0-Client"), `is`(notNullValue())) @@ -301,34 +301,38 @@ public class MfaApiClientTest { @Test public fun shouldGetAuthenticatorsSuccess(): Unit = runTest { val json = """[ - {"id": "sms|dev_123", "type": "oob", "authenticator_type": "oob", "active": true, "oob_channel": "sms"}, - {"id": "totp|dev_456", "type": "otp", "authenticator_type": "otp", "active": true} + {"id": "sms|dev_123", "type": "phone", "authenticator_type": "oob", "active": true, "oob_channel": "sms"}, + {"id": "voice|dev_123", "type": "phone", "authenticator_type": "oob", "active": true, "oob_channel": "voice"}, + {"id": "recovery|dev_789", "type": "recovery-code", "authenticator_type": "recovery-code", "active": true}, + {"id": "email|dev_456", "type": "email", "authenticator_type": "oob", "active": true, "oob_channel": "email"} ]""" enqueueMockResponse(json) - val authenticators = mfaClient.getAuthenticators(listOf("oob", "otp")).await() + val authenticators = mfaClient.getAuthenticators(listOf("phone", "email")).await() - assertThat(authenticators, hasSize(2)) + assertThat(authenticators, hasSize(3)) assertThat(authenticators[0].id, `is`("sms|dev_123")) - assertThat(authenticators[0].type, `is`("oob")) - assertThat(authenticators[1].id, `is`("totp|dev_456")) - assertThat(authenticators[1].type, `is`("otp")) + assertThat(authenticators[0].type, `is`("phone")) + assertThat(authenticators[1].id, `is`("voice|dev_123")) + assertThat(authenticators[1].type, `is`("phone")) + assertThat(authenticators[2].id, `is`("email|dev_456")) + assertThat(authenticators[2].type, `is`("email")) } @Test public fun shouldFilterAuthenticatorsByFactorsAllowed(): Unit = runTest { val json = """[ - {"id": "sms|dev_123", "type": "oob", "authenticator_type": "oob", "active": true, "oob_channel": "sms"}, - {"id": "totp|dev_456", "type": "otp", "authenticator_type": "otp", "active": true}, + {"id": "sms|dev_123", "type": "phone", "authenticator_type": "oob", "active": true, "oob_channel": "sms"}, + {"id": "email|dev_456", "type": "email", "authenticator_type": "oob", "active": true, "oob_channel": "email"}, {"id": "recovery|dev_789", "type": "recovery-code", "authenticator_type": "recovery-code", "active": true} ]""" enqueueMockResponse(json) - val authenticators = mfaClient.getAuthenticators(listOf("otp")).await() + val authenticators = mfaClient.getAuthenticators(listOf("recovery-code")).await() assertThat(authenticators, hasSize(1)) - assertThat(authenticators[0].id, `is`("totp|dev_456")) - assertThat(authenticators[0].type, `is`("otp")) + assertThat(authenticators[0].id, `is`("recovery|dev_789")) + assertThat(authenticators[0].type, `is`("recovery-code")) } @Test @@ -344,10 +348,10 @@ public class MfaApiClientTest { @Test public fun shouldIncludeAuthorizationHeaderInGetAuthenticators(): Unit = runTest { - val json = """[{"id": "sms|dev_123", "type": "oob", "active": true}]""" + val json = """[{"id": "sms|dev_123", "type": "phone", "authenticator_type": "oob", "oob_channel": "sms", "active": true}]""" enqueueMockResponse(json) - mfaClient.getAuthenticators(listOf("oob")).await() + mfaClient.getAuthenticators(listOf("phone")).await() val request = mockServer.takeRequest() assertThat(request.getHeader("Authorization"), `is`("Bearer $MFA_TOKEN")) @@ -361,7 +365,7 @@ public class MfaApiClientTest { val exception = assertThrows(MfaListAuthenticatorsException::class.java) { runTest { - mfaClient.getAuthenticators(listOf("oob")).await() + mfaClient.getAuthenticators(listOf("phone")).await() } } assertThat(exception.getCode(), `is`("access_denied")) @@ -372,7 +376,7 @@ public class MfaApiClientTest { @Test public fun shouldReturnEmptyListWhenNoMatchingFactors(): Unit = runTest { val json = """[ - {"id": "sms|dev_123", "type": "oob", "active": true} + {"id": "sms|dev_123", "type": "phone", "authenticator_type": "oob", "oob_channel": "sms", "active": true} ]""" enqueueMockResponse(json) @@ -477,11 +481,13 @@ public class MfaApiClientTest { @Test public fun shouldEnrollOtpSuccess(): Unit = runTest { + // Real /mfa/associate TOTP response shape: authenticator_type + secret + barcode_uri + // + recovery_codes. It does NOT contain id, auth_session or manual_input_code. val json = """{ - "id": "totp|dev_789", - "auth_session": "session_ghi", + "authenticator_type": "otp", + "secret": "JBSWY3DPEHPK3PXP", "barcode_uri": "otpauth://totp/Example:user@example.com?secret=JBSWY3DPEHPK3PXP&issuer=Example", - "manual_input_code": "JBSWY3DPEHPK3PXP" + "recovery_codes": ["ABCD1234EFGH5678"] }""" enqueueMockResponse(json) @@ -489,10 +495,14 @@ public class MfaApiClientTest { assertThat(challenge, `is`(instanceOf(TotpEnrollmentChallenge::class.java))) val totpChallenge = challenge as TotpEnrollmentChallenge - assertThat(totpChallenge.id, `is`("totp|dev_789")) - assertThat(totpChallenge.authSession, `is`("session_ghi")) + assertThat(totpChallenge.authenticatorType, `is`("otp")) + assertThat(totpChallenge.secret, `is`("JBSWY3DPEHPK3PXP")) assertThat(totpChallenge.barcodeUri, containsString("otpauth://")) - assertThat(totpChallenge.manualInputCode, `is`("JBSWY3DPEHPK3PXP")) + assertThat(totpChallenge.recoveryCodes, `is`(listOf("ABCD1234EFGH5678"))) + // Fields not returned by /mfa/associate must be null (not force-unwrapped non-null). + assertThat(totpChallenge.id, `is`(nullValue())) + assertThat(totpChallenge.authSession, `is`(nullValue())) + assertThat(totpChallenge.manualInputCode, `is`(nullValue())) } @Test @@ -839,12 +849,12 @@ public class MfaApiClientTest { @Test public fun shouldGetAuthenticatorsWithCallback(): Unit { - val json = """[{"id": "sms|dev_123", "type": "oob", "authenticator_type": "oob", "active": true}]""" + val json = """[{"id": "sms|dev_123", "type": "phone", "authenticator_type": "oob", "oob_channel": "sms", "active": true}]""" enqueueMockResponse(json) val callback = MockCallback, MfaListAuthenticatorsException>() - mfaClient.getAuthenticators(listOf("oob")) + mfaClient.getAuthenticators(listOf("phone")) .start(callback) ShadowLooper.idleMainLooper() From 36dcaf4140c4b625c02dca8d6f1103c90a5de9b6 Mon Sep 17 00:00:00 2001 From: utkrishtS Date: Wed, 8 Jul 2026 12:36:06 +0530 Subject: [PATCH 58/60] docs(mfa): document TotpEnrollmentChallenge nullable properties per endpoint --- .../auth0/android/result/EnrollmentChallenge.kt | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/auth0/src/main/java/com/auth0/android/result/EnrollmentChallenge.kt b/auth0/src/main/java/com/auth0/android/result/EnrollmentChallenge.kt index 24872c775..1ce73d986 100644 --- a/auth0/src/main/java/com/auth0/android/result/EnrollmentChallenge.kt +++ b/auth0/src/main/java/com/auth0/android/result/EnrollmentChallenge.kt @@ -69,6 +69,23 @@ public data class OobEnrollmentChallenge( * [secret] and [manualInputCode] both carry the human-readable key for manual entry into an * authenticator app; only one is populated depending on the endpoint. The [barcodeUri] * (`otpauth://` URI) is always present and embeds the same secret. + * + * @property id Identifier of the created authentication method. `null` on the `/mfa/associate` + * response (which does not return it); populated by the My Account `/authentication-methods` + * endpoint. + * @property authSession Authentication session for the enrollment. `null` on the + * `/mfa/associate` response; populated by the My Account `/authentication-methods` endpoint. + * @property barcodeUri The `otpauth://` URI to render as a QR code. Always present on both + * endpoints. + * @property manualInputCode Human-readable key for manual entry, returned only by the My Account + * `/authentication-methods` endpoint; `null` on the `/mfa/associate` response (which returns the + * key as [secret] instead). + * @property authenticatorType Low-level authenticator type (e.g. `otp`), returned only by the + * `/mfa/associate` endpoint; `null` on the My Account response. + * @property secret Human-readable key for manual entry, returned only by the `/mfa/associate` + * endpoint; `null` on the My Account response (which returns the key as [manualInputCode]). + * @property recoveryCodes Recovery codes generated during enrollment, returned only by the + * `/mfa/associate` endpoint; `null` on the My Account response. */ public data class TotpEnrollmentChallenge( @SerializedName("id") From d9764b160b1c6469267e9e51be8fc620ac06670e Mon Sep 17 00:00:00 2001 From: utkrishtS Date: Wed, 8 Jul 2026 16:16:53 +0530 Subject: [PATCH 59/60] Release 3.21.0 --- .version | 2 +- CHANGELOG.md | 7 +++++++ README.md | 2 +- 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/.version b/.version index b1f5ebeda..1d094fc2b 100644 --- a/.version +++ b/.version @@ -1 +1 @@ -3.20.0 \ No newline at end of file +3.21.0 \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 1354692e5..fa544fecb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Change Log +## [3.21.0](https://github.com/auth0/Auth0.Android/tree/3.21.0) (2026-07-08) +[Full Changelog](https://github.com/auth0/Auth0.Android/compare/3.20.0...3.21.0) + +**Added** +- fix: MFAClient getAuthenticators filtering based on Authenticator.type field [\#998](https://github.com/auth0/Auth0.Android/pull/998) ([utkrishtsahu](https://github.com/utkrishtsahu)) +- feat: Add password enrollment to MyAccount API [\#1003](https://github.com/auth0/Auth0.Android/pull/1003) ([pmathew92](https://github.com/pmathew92)) + ## [3.20.0](https://github.com/auth0/Auth0.Android/tree/3.20.0) (2026-06-29) [Full Changelog](https://github.com/auth0/Auth0.Android/compare/3.19.0...3.20.0) diff --git a/README.md b/README.md index 348158733..6b63f2aa4 100644 --- a/README.md +++ b/README.md @@ -66,7 +66,7 @@ To install Auth0.Android with [Gradle](https://gradle.org/), simply add the foll ```gradle dependencies { - implementation 'com.auth0.android:auth0:3.20.0' + implementation 'com.auth0.android:auth0:3.21.0' } ``` From 03c686af1301b80855a6b87f396a85c02c6bac21 Mon Sep 17 00:00:00 2001 From: Utkrisht Sahu Date: Wed, 8 Jul 2026 16:26:52 +0530 Subject: [PATCH 60/60] Reorganize changelog entries for clarity Moved 'fix' entry under 'Fixed' section in changelog. --- CHANGELOG.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fa544fecb..c3f9010f3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,9 +4,11 @@ [Full Changelog](https://github.com/auth0/Auth0.Android/compare/3.20.0...3.21.0) **Added** -- fix: MFAClient getAuthenticators filtering based on Authenticator.type field [\#998](https://github.com/auth0/Auth0.Android/pull/998) ([utkrishtsahu](https://github.com/utkrishtsahu)) - feat: Add password enrollment to MyAccount API [\#1003](https://github.com/auth0/Auth0.Android/pull/1003) ([pmathew92](https://github.com/pmathew92)) +**Fixed** +- fix: MFAClient getAuthenticators filtering based on Authenticator.type field [\#998](https://github.com/auth0/Auth0.Android/pull/998) ([utkrishtsahu](https://github.com/utkrishtsahu)) + ## [3.20.0](https://github.com/auth0/Auth0.Android/tree/3.20.0) (2026-06-29) [Full Changelog](https://github.com/auth0/Auth0.Android/compare/3.19.0...3.20.0)