From ca52e2f1fa4b9b332cb6ede7165139102d00ad8f Mon Sep 17 00:00:00 2001 From: utkrishtS Date: Mon, 26 Oct 2026 10:04:58 +0530 Subject: [PATCH 01/23] fix(storage): keep RSA key for PKCS1 migration on padding mismatch RSADecrypt deleted the RSA/AES keys on InvalidKeyException (and the AES key + legacy IV on ProviderException) before tryRecoverCurrentAESKey could run the PKCS1 migration, logging out users upgrading from SDK <= 3.12.x / 2.x. RSADecrypt now only throws CryptoException; recovery and cleanup stay in tryRecoverCurrentAESKey, so the MGF1 regeneration from #1048 is unchanged. Fixes auth0/react-native-auth0#1696 Co-Authored-By: Claude (cherry picked from commit 15b91f108e311e64a9a8f767f4308fb24550fe0f) --- .../authentication/storage/CryptoUtil.java | 32 ++-- .../storage/CryptoUtilTest.java | 181 ++++++++++++++++-- 2 files changed, 184 insertions(+), 29 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 3214487ff..1937f8af5 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 @@ -352,20 +352,22 @@ byte[] RSADecrypt(byte[] encryptedInput) throws IncompatibleDeviceException, Cry * - InvalidAlgorithmParameterException: * Thrown if the OAEP parameters are invalid or unsupported for this key. * - * This is NOT a device-level incompatibility -- the key can be deleted and regenerated - * with the MGF1 digest authorized. Delete the RSA key (and the AES key it protected, - * which is now unrecoverable) and wrap as CryptoException so the caller falls through to - * key regeneration instead of permanently blocking the user with INCOMPATIBLE_DEVICE. - * If the device genuinely cannot generate a compatible key, the subsequent - * getRSAKeyEntry() will throw IncompatibleDeviceException, preserving the terminal path. + * A legacy RSA key authorized only for PKCS1 padding (generated by SDK versions before + * the OAEP switch) is rejected here in the same way. + * + * This is NOT a device-level incompatibility, so wrap as CryptoException instead of + * permanently blocking the user with INCOMPATIBLE_DEVICE. Do not delete any key here: + * tryRecoverCurrentAESKey() first attempts the PKCS1 migration, which needs the existing + * RSA key and stored AES key. If the migration fails (e.g. MGF1 digest mismatch), it + * deletes both keys and a new RSA key pair is generated with the MGF1 digest authorized. + * If the device genuinely cannot generate a compatible key, getRSAKeyEntry() throws + * IncompatibleDeviceException, preserving the terminal path. * * Read more in https://developer.android.com/reference/javax/crypto/Cipher */ - Log.e(TAG, "RSA key rejected the cipher parameters (likely MGF1 digest mismatch on Keystore2). Recreating key.", e); - deleteRSAKeys(); - deleteAESKeys(); + Log.e(TAG, "RSA key rejected the cipher parameters (legacy PKCS1 key or MGF1 digest mismatch).", e); throw new CryptoException( - "The RSA key's authorized parameters are incompatible with the current cipher. The keys have been recreated; please retry.", e); + "The RSA key's authorized parameters are incompatible with the current cipher.", e); } catch (NoSuchAlgorithmException | NoSuchPaddingException e) { /* * - NoSuchPaddingException: @@ -390,13 +392,13 @@ byte[] RSADecrypt(byte[] encryptedInput) throws IncompatibleDeviceException, Cry * 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. + * This is NOT a device-level incompatibility. Wrapping as CryptoException (rather + * than IncompatibleDeviceException) lets tryRecoverCurrentAESKey() attempt the + * PKCS1 migration first and fall back to key cleanup and regeneration, instead of + * permanently blocking the user. Keys are not deleted here: the migration needs the + * stored IV to read credentials saved in the legacy format (SDK 2.x). */ 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) { 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 02a1baaeb..deab72fa6 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 @@ -705,8 +705,8 @@ public void shouldRSADecryptData() throws Exception { } @Test - public void shouldRecreateKeysAndThrowCryptoExceptionOnInvalidKeyExceptionWhenTryingToRSADecrypt() throws Exception { - Assert.assertThrows("The RSA key's authorized parameters are incompatible with the current cipher. The keys have been recreated; please retry.", CryptoException.class, () -> { + public void shouldKeepKeysAndThrowCryptoExceptionOnInvalidKeyExceptionWhenTryingToRSADecrypt() throws Exception { + Assert.assertThrows("The RSA key's authorized parameters are incompatible with the current cipher.", CryptoException.class, () -> { byte[] sampleBytes = new byte[0]; PrivateKey privateKey = Mockito.mock(PrivateKey.class); KeyStore.PrivateKeyEntry privateKeyEntry = Mockito.mock(KeyStore.PrivateKeyEntry.class); @@ -718,17 +718,14 @@ public void shouldRecreateKeysAndThrowCryptoExceptionOnInvalidKeyExceptionWhenTr cryptoUtil.RSADecrypt(sampleBytes); }); - Mockito.verify(keyStore).deleteEntry(KEY_ALIAS); - Mockito.verify(keyStore).deleteEntry(OLD_KEY_ALIAS); - Mockito.verify(storage).remove(KEY_ALIAS); - Mockito.verify(storage).remove(KEY_ALIAS + "_iv"); - Mockito.verify(storage).remove(OLD_KEY_ALIAS); - Mockito.verify(storage).remove(OLD_KEY_ALIAS + "_iv"); + // Keys are kept so that tryRecoverCurrentAESKey can attempt the PKCS1 migration first + Mockito.verify(keyStore, never()).deleteEntry(anyString()); + Mockito.verify(storage, never()).remove(anyString()); } @Test - public void shouldRecreateKeysAndThrowCryptoExceptionOnInvalidAlgorithmParameterExceptionWhenTryingToRSADecrypt() throws Exception { - Assert.assertThrows("The RSA key's authorized parameters are incompatible with the current cipher. The keys have been recreated; please retry.", CryptoException.class, () -> { + public void shouldKeepKeysAndThrowCryptoExceptionOnInvalidAlgorithmParameterExceptionWhenTryingToRSADecrypt() throws Exception { + Assert.assertThrows("The RSA key's authorized parameters are incompatible with the current cipher.", CryptoException.class, () -> { byte[] sampleBytes = new byte[0]; PrivateKey privateKey = Mockito.mock(PrivateKey.class); KeyStore.PrivateKeyEntry privateKeyEntry = Mockito.mock(KeyStore.PrivateKeyEntry.class); @@ -740,10 +737,8 @@ public void shouldRecreateKeysAndThrowCryptoExceptionOnInvalidAlgorithmParameter cryptoUtil.RSADecrypt(sampleBytes); }); - 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); + Mockito.verify(keyStore, never()).deleteEntry(anyString()); + Mockito.verify(storage, never()).remove(anyString()); } @Test @@ -1460,6 +1455,26 @@ public void shouldThrowButNotDeleteAESKeysOnIllegalBlockSizeExceptionWhenTryingT } + /** + * Stores an RSA key at KEY_ALIAS that stays in the KeyStore until deleteEntry(KEY_ALIAS) is + * called, as on a real device. Lets the real RSADecrypt run before the PKCS1 migration. + */ + private PrivateKey givenStatefulRSAKey(CryptoUtil cryptoUtil) throws Exception { + PrivateKey privateKey = mock(PrivateKey.class); + KeyStore.PrivateKeyEntry entry = mock(KeyStore.PrivateKeyEntry.class); + when(entry.getPrivateKey()).thenReturn(privateKey); + final boolean[] present = {true}; + when(keyStore.containsAlias(KEY_ALIAS)).thenAnswer(invocation -> present[0]); + Mockito.doAnswer(invocation -> { + present[0] = false; + return null; + }).when(keyStore).deleteEntry(KEY_ALIAS); + when(keyStore.getEntry(eq(KEY_ALIAS), nullable(KeyStore.ProtectionParameter.class))) + .thenAnswer(invocation -> present[0] ? entry : null); + doReturn(entry).when(cryptoUtil).getRSAKeyEntry(); + return privateKey; + } + @Test public void shouldDetectAndMigratePKCS1KeyToOAEP() throws Exception { CryptoUtil cryptoUtil = newCryptoUtilSpy(); @@ -1699,6 +1714,9 @@ public void shouldWrapProviderExceptionFromCipherInitInRSADecryptAsCryptoExcepti cryptoUtil.RSADecrypt(new byte[]{1, 2, 3}); }); + + // The stored AES key and legacy IV are kept for the PKCS1 migration + Mockito.verify(storage, never()).remove(anyString()); } @Test @@ -1755,6 +1773,141 @@ public void shouldTriggerPKCS1MigrationWhenRSADecryptThrowsProviderException() t Mockito.verify(keyStore).deleteEntry(KEY_ALIAS); } + /* + * The tests below run the real RSADecrypt (no stub) before the PKCS1 migration, as on a device: + * a PKCS1-only RSA key from an older SDK version rejects the OAEP cipher. + */ + + @Test + public void shouldMigratePKCS1KeyWhenRealRSADecryptThrowsInvalidKeyException() throws Exception { + CryptoUtil cryptoUtil = newCryptoUtilSpy(); + + byte[] aesKeyBytes = new byte[32]; + Arrays.fill(aesKeyBytes, (byte) 0xCD); + byte[] encryptedAESPKCS1 = new byte[]{10, 11, 12, 13}; + byte[] reEncryptedOAEP = new byte[]{20, 21, 22, 23}; + String encodedPKCS1 = "pkcs1_encoded"; + String encodedOAEP = "oaep_encoded"; + + when(storage.retrieveString(KEY_ALIAS)).thenReturn(encodedPKCS1); + base64Mock.when(() -> Base64.decode(encodedPKCS1, Base64.DEFAULT)).thenReturn(encryptedAESPKCS1); + base64Mock.when(() -> Base64.encode(reEncryptedOAEP, Base64.DEFAULT)) + .thenReturn(encodedOAEP.getBytes(StandardCharsets.UTF_8)); + + PrivateKey privateKey = givenStatefulRSAKey(cryptoUtil); + doThrow(new InvalidKeyException("Incompatible padding mode")) + .when(rsaOaepCipher).init(eq(Cipher.DECRYPT_MODE), eq(privateKey), any(AlgorithmParameterSpec.class)); + when(rsaPkcs1Cipher.doFinal(encryptedAESPKCS1)).thenReturn(aesKeyBytes); + doReturn(reEncryptedOAEP).when(cryptoUtil).RSAEncrypt(aesKeyBytes); + + byte[] result = cryptoUtil.getAESKey(); + + assertThat(result, is(aesKeyBytes)); + Mockito.verify(rsaPkcs1Cipher).init(Cipher.DECRYPT_MODE, privateKey); + Mockito.verify(storage).store(KEY_ALIAS, encodedOAEP); + Mockito.verify(keyGenerator, never()).generateKey(); + } + + @Test + public void shouldMigratePKCS1KeyAndKeepLegacyIVWhenRealRSADecryptThrowsProviderException() throws Exception { + CryptoUtil cryptoUtil = newCryptoUtilSpy(); + + byte[] aesKeyBytes = new byte[32]; + Arrays.fill(aesKeyBytes, (byte) 0xCD); + byte[] encryptedAESPKCS1 = new byte[]{10, 11, 12, 13}; + byte[] reEncryptedOAEP = new byte[]{20, 21, 22, 23}; + String encodedPKCS1 = "pkcs1_encoded"; + String encodedOAEP = "oaep_encoded"; + + when(storage.retrieveString(KEY_ALIAS)).thenReturn(encodedPKCS1); + // Credentials saved by SDK 2.x use the legacy format, whose IV is stored separately + when(storage.retrieveString(KEY_ALIAS + "_iv")).thenReturn("legacy_iv"); + base64Mock.when(() -> Base64.decode(encodedPKCS1, Base64.DEFAULT)).thenReturn(encryptedAESPKCS1); + base64Mock.when(() -> Base64.encode(reEncryptedOAEP, Base64.DEFAULT)) + .thenReturn(encodedOAEP.getBytes(StandardCharsets.UTF_8)); + + PrivateKey privateKey = givenStatefulRSAKey(cryptoUtil); + doThrow(new ProviderException(new KeyStoreException("Incompatible padding mode"))) + .when(rsaOaepCipher).init(eq(Cipher.DECRYPT_MODE), eq(privateKey), any(AlgorithmParameterSpec.class)); + 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(storage, never()).remove(KEY_ALIAS + "_iv"); + Mockito.verify(storage, never()).remove(OLD_KEY_ALIAS + "_iv"); + } + + @Test + public void shouldRegenerateKeysWithoutIncompatibleDeviceWhenMgf1DigestIsRejected() throws Exception { + CryptoUtil cryptoUtil = newCryptoUtilSpy(); + + // AES key wrapped by an OAEP RSA key whose authorized MGF1 digests exclude SHA-1: + // both the OAEP and the PKCS1 cipher are rejected, so the key can't be recovered + byte[] encryptedAESOAEP = new byte[]{10, 11, 12, 13}; + String encodedOAEP = "oaep_encoded"; + when(storage.retrieveString(KEY_ALIAS)).thenReturn(encodedOAEP); + when(storage.retrieveString(OLD_KEY_ALIAS)).thenReturn(null); + base64Mock.when(() -> Base64.decode(encodedOAEP, Base64.DEFAULT)).thenReturn(encryptedAESOAEP); + + PrivateKey privateKey = givenStatefulRSAKey(cryptoUtil); + doThrow(new InvalidKeyException("Incompatible MGF digest")) + .when(rsaOaepCipher).init(eq(Cipher.DECRYPT_MODE), eq(privateKey), any(AlgorithmParameterSpec.class)); + doThrow(new InvalidKeyException("Incompatible padding mode")) + .when(rsaPkcs1Cipher).init(Cipher.DECRYPT_MODE, privateKey); + + byte[] newAESKey = new byte[32]; + Arrays.fill(newAESKey, (byte) 0xEE); + SecretKey newSecretKey = mock(SecretKey.class); + when(newSecretKey.getEncoded()).thenReturn(newAESKey); + when(keyGenerator.generateKey()).thenReturn(newSecretKey); + byte[] encryptedNewAES = new byte[]{40, 41, 42}; + doReturn(encryptedNewAES).when(cryptoUtil).RSAEncrypt(newAESKey); + base64Mock.when(() -> Base64.encode(encryptedNewAES, Base64.DEFAULT)) + .thenReturn("fresh_key".getBytes(StandardCharsets.UTF_8)); + + // Must not throw IncompatibleDeviceException + byte[] result = cryptoUtil.getAESKey(); + + assertThat(result, is(newAESKey)); + Mockito.verify(keyStore, Mockito.atLeastOnce()).deleteEntry(KEY_ALIAS); + Mockito.verify(storage).store(KEY_ALIAS, "fresh_key"); + } + + @Test + public void shouldMigratePKCS1KeyWhenEncryptIsTheFirstOperationAfterUpgrade() throws Exception { + CryptoUtil cryptoUtil = newCryptoUtilSpy(); + + byte[] aesKeyBytes = new byte[32]; + Arrays.fill(aesKeyBytes, (byte) 0xCD); + byte[] encryptedAESPKCS1 = new byte[]{10, 11, 12, 13}; + byte[] reEncryptedOAEP = new byte[]{20, 21, 22, 23}; + String encodedPKCS1 = "pkcs1_encoded"; + + when(storage.retrieveString(KEY_ALIAS)).thenReturn(encodedPKCS1); + base64Mock.when(() -> Base64.decode(encodedPKCS1, Base64.DEFAULT)).thenReturn(encryptedAESPKCS1); + base64Mock.when(() -> Base64.encode(reEncryptedOAEP, Base64.DEFAULT)) + .thenReturn("oaep_encoded".getBytes(StandardCharsets.UTF_8)); + + PrivateKey privateKey = givenStatefulRSAKey(cryptoUtil); + doThrow(new InvalidKeyException("Incompatible padding mode")) + .when(rsaOaepCipher).init(eq(Cipher.DECRYPT_MODE), eq(privateKey), any(AlgorithmParameterSpec.class)); + when(rsaPkcs1Cipher.doFinal(encryptedAESPKCS1)).thenReturn(aesKeyBytes); + doReturn(reEncryptedOAEP).when(cryptoUtil).RSAEncrypt(aesKeyBytes); + + when(aesCipher.doFinal(any(byte[].class))).thenReturn(new byte[]{50, 51, 52}); + when(aesCipher.getIV()).thenReturn(new byte[12]); + + cryptoUtil.encrypt(new byte[]{1, 2, 3}); + + ArgumentCaptor keyCaptor = ArgumentCaptor.forClass(SecretKey.class); + Mockito.verify(aesCipher).init(eq(Cipher.ENCRYPT_MODE), keyCaptor.capture()); + assertThat(keyCaptor.getValue().getEncoded(), is(aesKeyBytes)); + Mockito.verify(keyGenerator, never()).generateKey(); + } + @Test public void shouldDeleteOldRSAKeyBeforeReEncryptingInTryMigrateLegacyAESKey() throws Exception { CryptoUtil cryptoUtil = newCryptoUtilSpy(); From 8964fe3bf530da2b191a4ae261f00a569954b0d9 Mon Sep 17 00:00:00 2001 From: utkrishtS Date: Mon, 26 Oct 2026 12:19:52 +0530 Subject: [PATCH 02/23] Release 4.2.1 --- .version | 2 +- CHANGELOG.md | 7 +++++++ README.md | 2 +- 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/.version b/.version index ef8d7569d..d87edbfc1 100644 --- a/.version +++ b/.version @@ -1 +1 @@ -4.2.0 \ No newline at end of file +4.2.1 \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index e18ec113d..01c2611ff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Change Log +## [4.2.1](https://github.com/auth0/Auth0.Android/tree/4.2.1) (2026-10-26) +[Full Changelog](https://github.com/auth0/Auth0.Android/compare/4.2.0...4.2.1) + +**Fixed** +- fix(storage): keep RSA key for PKCS1 migration on padding mismatch [\#1110](https://github.com/auth0/Auth0.Android/pull/1110) ([utkrishtsahu](https://github.com/utkrishtsahu)) +- fix: keep scope case when appending the openid scope [\#1102](https://github.com/auth0/Auth0.Android/pull/1102) ([RaphaelFakhri](https://github.com/RaphaelFakhri)) + ## [4.2.0](https://github.com/auth0/Auth0.Android/tree/4.2.0) (2026-10-22) [Full Changelog](https://github.com/auth0/Auth0.Android/compare/4.1.0...4.2.0) diff --git a/README.md b/README.md index 4bc684f88..ce1701bce 100644 --- a/README.md +++ b/README.md @@ -51,7 +51,7 @@ To install Auth0.Android with [Gradle](https://gradle.org/), simply add the foll ```gradle dependencies { - implementation 'com.auth0.android:auth0:4.2.0' + implementation 'com.auth0.android:auth0:4.2.1' } ``` From 720c941ed1d0a13d45182833a9cc898d0e01d1fb Mon Sep 17 00:00:00 2001 From: utkrishtS Date: Mon, 26 Oct 2026 12:29:14 +0530 Subject: [PATCH 03/23] docs(changelog): add 3.21.3 release history --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 01c2611ff..37da1b107 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,12 @@ - fix(storage): keep RSA key for PKCS1 migration on padding mismatch [\#1110](https://github.com/auth0/Auth0.Android/pull/1110) ([utkrishtsahu](https://github.com/utkrishtsahu)) - fix: keep scope case when appending the openid scope [\#1102](https://github.com/auth0/Auth0.Android/pull/1102) ([RaphaelFakhri](https://github.com/RaphaelFakhri)) +## [3.21.3](https://github.com/auth0/Auth0.Android/tree/3.21.3) (2026-10-26) +[Full Changelog](https://github.com/auth0/Auth0.Android/compare/3.21.2...3.21.3) + +**Fixed** +- fix: keep RSA key for PKCS1 migration on padding mismatch [\#1107](https://github.com/auth0/Auth0.Android/pull/1107) ([utkrishtsahu](https://github.com/utkrishtsahu)) + ## [4.2.0](https://github.com/auth0/Auth0.Android/tree/4.2.0) (2026-10-22) [Full Changelog](https://github.com/auth0/Auth0.Android/compare/4.1.0...4.2.0) From c3ba7b957e327d9af67a2ca6ef64b5b41913c6c6 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 26 Oct 2026 14:04:41 +0530 Subject: [PATCH 04/23] chore(deps): bump the codeql group with 3 updates (#1114) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/codeql.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index ff8dd8e97..a9324ec5b 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -45,15 +45,15 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Initialize CodeQL - uses: github/codeql-action/init@6f2ac81f5cd81fe351a197ba0b00d91d03cd2aee # v4.39.0 + uses: github/codeql-action/init@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4.39.1 with: languages: ${{ matrix.language }} queries: +security-and-quality - name: Autobuild - uses: github/codeql-action/autobuild@6f2ac81f5cd81fe351a197ba0b00d91d03cd2aee # v4.39.0 + uses: github/codeql-action/autobuild@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4.39.1 - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@6f2ac81f5cd81fe351a197ba0b00d91d03cd2aee # v4.39.0 + uses: github/codeql-action/analyze@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4.39.1 with: category: "/language:${{ matrix.language }}" From 93661292786795b8546d96d1c4a288d698d4d2a8 Mon Sep 17 00:00:00 2001 From: utkrishtS Date: Wed, 28 Oct 2026 07:54:33 +0530 Subject: [PATCH 05/23] feat(myaccount): add profile get and update APIs Add getUserProfile and updateUserProfile to MyAccountAPIClient for GET/PATCH /me/v1/profile, with MyAccountProfile, ProfileFieldPolicy, GetUserProfileOptions and UpdateUserProfileRequest. Includes unit tests and My Account API examples. Co-Authored-By: Claude --- .../myaccount/GetUserProfileOptions.kt | 15 + .../android/myaccount/MyAccountAPIClient.kt | 106 ++++ .../myaccount/UpdateUserProfileRequest.kt | 21 + .../auth0/android/result/MyAccountProfile.kt | 123 +++++ .../MyAccountAPIClientProfileTest.kt | 461 ++++++++++++++++++ .../android/util/MyAccountAPIMockServer.kt | 99 ++++ examples/my-account-api.md | 119 +++++ 7 files changed, 944 insertions(+) create mode 100644 auth0/src/main/java/com/auth0/android/myaccount/GetUserProfileOptions.kt create mode 100644 auth0/src/main/java/com/auth0/android/myaccount/UpdateUserProfileRequest.kt create mode 100644 auth0/src/main/java/com/auth0/android/result/MyAccountProfile.kt create mode 100644 auth0/src/test/java/com/auth0/android/myaccount/MyAccountAPIClientProfileTest.kt diff --git a/auth0/src/main/java/com/auth0/android/myaccount/GetUserProfileOptions.kt b/auth0/src/main/java/com/auth0/android/myaccount/GetUserProfileOptions.kt new file mode 100644 index 000000000..acf51740e --- /dev/null +++ b/auth0/src/main/java/com/auth0/android/myaccount/GetUserProfileOptions.kt @@ -0,0 +1,15 @@ +package com.auth0.android.myaccount + +/** + * Options to control which fields are returned by [MyAccountAPIClient.getUserProfile]. + * + * @param fields the fields to include in (or exclude from) the response, e.g. + * `listOf("given_name", "picture", "profile_policy")`. When set and `profile_policy` is not + * included, the returned profile's `profilePolicy` will be null. + * @param includeFields whether [fields] are included (`true`) or excluded (`false`) from the + * response. Only sent when [fields] is not empty. Defaults to inclusion on the server. + */ +public data class GetUserProfileOptions @JvmOverloads constructor( + public val fields: List? = null, + public val includeFields: Boolean? = null +) 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 63e6f922c..3f8d161d6 100644 --- a/auth0/src/main/java/com/auth0/android/myaccount/MyAccountAPIClient.kt +++ b/auth0/src/main/java/com/auth0/android/myaccount/MyAccountAPIClient.kt @@ -22,6 +22,7 @@ import com.auth0.android.result.AuthenticationMethods import com.auth0.android.result.EnrollmentChallenge import com.auth0.android.result.Factor import com.auth0.android.result.Factors +import com.auth0.android.result.MyAccountProfile import com.auth0.android.result.PasskeyAuthenticationMethod import com.auth0.android.result.PasskeyEnrollmentChallenge import com.auth0.android.result.PasskeyRegistrationChallenge @@ -843,6 +844,103 @@ public class MyAccountAPIClient @VisibleForTesting(otherwise = VisibleForTesting .addHeader(AUTHORIZATION_KEY, authorizationHeader) } + /** + * Retrieves the current user's profile, including a `profile_policy` descriptor for each + * field that tells whether it can be edited by the user and why. + * + * ## Scopes Required + * `read:me:profile` + * + * ## Usage + * + * ```kotlin + * val auth0 = Auth0.getInstance("YOUR_CLIENT_ID", "YOUR_DOMAIN") + * val apiClient = MyAccountAPIClient(auth0, accessToken) + * + * apiClient.getUserProfile() + * .start(object : Callback { + * override fun onSuccess(result: MyAccountProfile) { + * Log.d("MyApp", "Profile: $result") + * } + * override fun onFailure(error: MyAccountException) { + * Log.e("MyApp", "Failed with: ${error.message}") + * } + * }) + * ``` + * When [GetUserProfileOptions.fields] is set and does not include `profile_policy`, + * the returned profile's `profilePolicy` will be null. The keys of `profilePolicy` are + * JSON Pointers to the fields, e.g. `/given_name`. + * + * @param options optional selection of the fields to return. + * @return a request that will yield the user's profile. + */ + @JvmOverloads + public fun getUserProfile( + options: GetUserProfileOptions? = null + ): Request { + val url = getDomainUrlBuilder().apply { + addPathSegment(PROFILE) + val fields = options?.fields + if (!fields.isNullOrEmpty()) { + addQueryParameter(FIELDS_KEY, fields.joinToString(",")) + options.includeFields?.let { addQueryParameter(INCLUDE_FIELDS_KEY, it.toString()) } + } + }.build() + return factory.get( + url.toString(), + GsonAdapter(MyAccountProfile::class.java, gson), + dPoP + ) + .addHeader(AUTHORIZATION_KEY, authorizationHeader) + } + + /** + * Updates the current user's editable profile fields. + * Fields left as `null` in the [request] remain unchanged. + * + * ## Scopes Required + * `update:me:profile` + * + * ## Usage + * + * ```kotlin + * val auth0 = Auth0.getInstance("YOUR_CLIENT_ID", "YOUR_DOMAIN") + * val apiClient = MyAccountAPIClient(auth0, accessToken) + * + * val request = UpdateUserProfileRequest(givenName = "Jane", nickname = "jane") + * apiClient.updateUserProfile(request) + * .start(object : Callback { + * override fun onSuccess(result: MyAccountProfile) { + * Log.d("MyApp", "Updated profile: $result") + * } + * override fun onFailure(error: MyAccountException) { + * // On 400, error.validationErrors describes each rejected field + * } + * }) + * ``` + * @param request the changes to apply. + * @return a request that will yield the full, updated profile. + */ + public fun updateUserProfile( + request: UpdateUserProfileRequest + ): Request { + val url = getDomainUrlBuilder().addPathSegment(PROFILE).build() + val params = ParameterBuilder.newBuilder().apply { + request.givenName?.let { set(GIVEN_NAME_KEY, it) } + request.familyName?.let { set(FAMILY_NAME_KEY, it) } + request.name?.let { set(NAME_KEY, it) } + request.nickname?.let { set(NICKNAME_KEY, it) } + request.picture?.let { set(PICTURE_KEY, it) } + }.asDictionary() + return factory.patch( + url.toString(), + GsonAdapter(MyAccountProfile::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. @@ -931,6 +1029,14 @@ public class MyAccountAPIClient @VisibleForTesting(otherwise = VisibleForTesting private const val FACTORS = "factors" private const val PHONE_NUMBER_KEY = "phone_number" private const val EMAIL_KEY = "email" + private const val PROFILE = "profile" + private const val FIELDS_KEY = "fields" + private const val INCLUDE_FIELDS_KEY = "include_fields" + private const val GIVEN_NAME_KEY = "given_name" + private const val FAMILY_NAME_KEY = "family_name" + private const val NAME_KEY = "name" + private const val NICKNAME_KEY = "nickname" + private const val PICTURE_KEY = "picture" private fun createErrorAdapter(): ErrorAdapter { val mapAdapter = forMap(GsonProvider.gson) diff --git a/auth0/src/main/java/com/auth0/android/myaccount/UpdateUserProfileRequest.kt b/auth0/src/main/java/com/auth0/android/myaccount/UpdateUserProfileRequest.kt new file mode 100644 index 000000000..370146209 --- /dev/null +++ b/auth0/src/main/java/com/auth0/android/myaccount/UpdateUserProfileRequest.kt @@ -0,0 +1,21 @@ +package com.auth0.android.myaccount + +/** + * The changes to apply with [MyAccountAPIClient.updateUserProfile]. + * + * Properties left as `null` are not sent and remain unchanged. A request with every property + * `null` is valid and returns the current profile. + * + * @param givenName the user's given name + * @param familyName the user's family name + * @param name the user's full name + * @param nickname the user's nickname + * @param picture the URL of the user's picture + */ +public data class UpdateUserProfileRequest @JvmOverloads constructor( + public val givenName: String? = null, + public val familyName: String? = null, + public val name: String? = null, + public val nickname: String? = null, + public val picture: String? = null +) diff --git a/auth0/src/main/java/com/auth0/android/result/MyAccountProfile.kt b/auth0/src/main/java/com/auth0/android/result/MyAccountProfile.kt new file mode 100644 index 000000000..30a2ecf3c --- /dev/null +++ b/auth0/src/main/java/com/auth0/android/result/MyAccountProfile.kt @@ -0,0 +1,123 @@ +package com.auth0.android.result + +import com.google.gson.JsonDeserializationContext +import com.google.gson.JsonDeserializer +import com.google.gson.JsonElement +import com.google.gson.JsonObject +import com.google.gson.annotations.JsonAdapter +import com.google.gson.annotations.SerializedName +import java.lang.reflect.Type + +/** + * The current user's profile, as returned by the My Account API `/me/v1/profile` endpoint. + * + * Every property is nullable: the server omits fields that are not set for the user, and + * any field can be trimmed from the response by using + * [com.auth0.android.myaccount.GetUserProfileOptions.fields]. + * + * [userId], [emailVerified] and [phoneVerified] are read-only: they can't be changed with + * [com.auth0.android.myaccount.UpdateUserProfileRequest]. + */ +public data class MyAccountProfile( + @SerializedName("user_id") + public val userId: String? = null, + @SerializedName("given_name") + public val givenName: String? = null, + @SerializedName("family_name") + public val familyName: String? = null, + @SerializedName("name") + public val name: String? = null, + @SerializedName("nickname") + public val nickname: String? = null, + @SerializedName("picture") + public val picture: String? = null, + @SerializedName("email") + public val email: String? = null, + @SerializedName("email_verified") + public val emailVerified: Boolean? = null, + @SerializedName("phone_number") + public val phoneNumber: String? = null, + @SerializedName("phone_verified") + public val phoneVerified: Boolean? = null, + @SerializedName("username") + public val username: String? = null, + @SerializedName("created_at") + public val createdAt: String? = null, + @SerializedName("updated_at") + public val updatedAt: String? = null, + /** + * Describes, per field, whether it can be edited by the user and why. Keyed by a JSON + * Pointer to the field, e.g. `/given_name`. + * Absent when `profile_policy` is excluded from the requested fields. + */ + @SerializedName("profile_policy") + public val profilePolicy: Map? = null +) + +/** + * Describes whether a profile field can be edited by the user and why. + */ +@JsonAdapter(ProfileFieldPolicy.Deserializer::class) +public data class ProfileFieldPolicy( + /** + * Whether the field is editable. Null if the server returned a value unknown to this SDK version. + */ + @SerializedName("access") + public val access: ProfileFieldAccess? = null, + /** + * Where the field's value comes from. Null if the server returned a value unknown to this SDK version. + */ + @SerializedName("source") + public val source: ProfileFieldSource? = null, + /** + * Machine-readable reason a field is read-only, e.g. `managed_by_identity_provider`. + * Only set when [access] is read-only. New values may be added by the server. + */ + @SerializedName("reason") + public val reason: String? = null +) { + + // Maps unknown access/source values to null instead of failing to parse the profile. + internal class Deserializer : JsonDeserializer { + override fun deserialize( + json: JsonElement, + typeOfT: Type, + context: JsonDeserializationContext + ): ProfileFieldPolicy { + val jsonObject = json.asJsonObject + return ProfileFieldPolicy( + access = ProfileFieldAccess.fromValue(jsonObject.stringOrNull("access")), + source = ProfileFieldSource.fromValue(jsonObject.stringOrNull("source")), + reason = jsonObject.stringOrNull("reason") + ) + } + + private fun JsonObject.stringOrNull(key: String): String? = + get(key)?.takeIf { it.isJsonPrimitive }?.asString + } +} + +/** + * Whether a profile field can be edited by the user. + */ +public enum class ProfileFieldAccess(public val value: String) { + READ_ONLY("read_only"), + READ_WRITE("read_write"); + + internal companion object { + fun fromValue(value: String?): ProfileFieldAccess? = entries.firstOrNull { it.value == value } + } +} + +/** + * Where a profile field's value comes from. + */ +public enum class ProfileFieldSource(public val value: String) { + USER("user"), + IDP("idp"), + SYSTEM("system"); + + internal companion object { + fun fromValue(value: String?): ProfileFieldSource? = entries.firstOrNull { it.value == value } + } +} diff --git a/auth0/src/test/java/com/auth0/android/myaccount/MyAccountAPIClientProfileTest.kt b/auth0/src/test/java/com/auth0/android/myaccount/MyAccountAPIClientProfileTest.kt new file mode 100644 index 000000000..429d444c3 --- /dev/null +++ b/auth0/src/test/java/com/auth0/android/myaccount/MyAccountAPIClientProfileTest.kt @@ -0,0 +1,461 @@ +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.result.MyAccountProfile +import com.auth0.android.result.ProfileFieldAccess +import com.auth0.android.result.ProfileFieldSource +import com.auth0.android.util.MockMyAccountCallback +import com.auth0.android.util.MyAccountAPIMockServer +import com.auth0.android.util.SSLTestUtils.testClient +import com.google.gson.Gson +import com.google.gson.reflect.TypeToken +import kotlinx.coroutines.test.runTest +import okhttp3.mockwebserver.RecordedRequest +import org.hamcrest.MatcherAssert.assertThat +import org.hamcrest.Matchers +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.shadows.ShadowLooper + +@RunWith(RobolectricTestRunner::class) +@Config(manifest = Config.NONE) +public class MyAccountAPIClientProfileTest { + + private lateinit var client: MyAccountAPIClient + private lateinit var mockAPI: MyAccountAPIMockServer + private lateinit var mockKeyStore: DPoPKeyStore + private lateinit var mockContext: Context + + @Before + public fun setUp() { + mockAPI = MyAccountAPIMockServer() + mockKeyStore = mock() + mockContext = mock() + whenever(mockContext.applicationContext).thenReturn(mockContext) + DPoPUtil.keyStore = mockKeyStore + client = MyAccountAPIClient(auth0, ACCESS_TOKEN) + } + + @After + public fun tearDown() { + mockAPI.shutdown() + } + + // getUserProfile + + @Test + public fun `getUserProfile should send a GET request without query parameters by default`() { + mockAPI.willReturnUserProfile() + + client.getUserProfile().execute() + + val request = mockAPI.takeRequest() + assertThat(request.method, Matchers.`is`("GET")) + assertThat(request.path, Matchers.`is`("/me/v1/profile")) + } + + @Test + public fun `getUserProfile should not send query parameters when fields is empty`() { + mockAPI.willReturnUserProfile() + + client.getUserProfile(GetUserProfileOptions(fields = emptyList(), includeFields = true)) + .execute() + + assertThat(mockAPI.takeRequest().path, Matchers.`is`("/me/v1/profile")) + } + + @Test + public fun `getUserProfile should send comma-joined fields without include_fields when not set`() { + mockAPI.willReturnUserProfile() + + client.getUserProfile(GetUserProfileOptions(fields = listOf("given_name", "picture"))) + .execute() + + val url = mockAPI.takeRequest().requestUrl!! + assertThat(url.queryParameter("fields"), Matchers.`is`("given_name,picture")) + assertThat(url.queryParameterNames.contains("include_fields"), Matchers.`is`(false)) + } + + @Test + public fun `getUserProfile should send include_fields true`() { + mockAPI.willReturnUserProfile() + + client.getUserProfile(GetUserProfileOptions(listOf("given_name"), true)).execute() + + val url = mockAPI.takeRequest().requestUrl!! + assertThat(url.queryParameter("fields"), Matchers.`is`("given_name")) + assertThat(url.queryParameter("include_fields"), Matchers.`is`("true")) + } + + @Test + public fun `getUserProfile should send include_fields false`() { + mockAPI.willReturnUserProfile() + + client.getUserProfile(GetUserProfileOptions(listOf("profile_policy"), false)).execute() + + val url = mockAPI.takeRequest().requestUrl!! + assertThat(url.queryParameter("fields"), Matchers.`is`("profile_policy")) + assertThat(url.queryParameter("include_fields"), Matchers.`is`("false")) + } + + @Test + public fun `getUserProfile should parse every profile field`() { + mockAPI.willReturnUserProfile() + + val profile = client.getUserProfile().execute() + + assertThat(profile.userId, Matchers.`is`("auth0|123")) + assertThat(profile.givenName, Matchers.`is`("Jane")) + assertThat(profile.familyName, Matchers.`is`("Doe")) + assertThat(profile.name, Matchers.`is`("Jane Doe")) + assertThat(profile.nickname, Matchers.`is`("jane")) + assertThat(profile.picture, Matchers.`is`("https://example.com/jane.png")) + assertThat(profile.email, Matchers.`is`("jane@example.com")) + assertThat(profile.emailVerified, Matchers.`is`(true)) + assertThat(profile.phoneNumber, Matchers.`is`("+15555550100")) + assertThat(profile.phoneVerified, Matchers.`is`(false)) + assertThat(profile.username, Matchers.`is`("janedoe")) + assertThat(profile.createdAt, Matchers.`is`("2026-01-01T00:00:00.000Z")) + assertThat(profile.updatedAt, Matchers.`is`("2026-02-01T00:00:00.000Z")) + } + + @Test + public fun `getUserProfile should parse profile_policy`() { + mockAPI.willReturnUserProfile() + + val policy = client.getUserProfile().execute().profilePolicy!! + + val givenName = policy["/given_name"]!! + assertThat(givenName.access, Matchers.`is`(ProfileFieldAccess.READ_ONLY)) + assertThat(givenName.source, Matchers.`is`(ProfileFieldSource.IDP)) + assertThat(givenName.reason, Matchers.`is`("managed_by_identity_provider")) + + val nickname = policy["/nickname"]!! + assertThat(nickname.access, Matchers.`is`(ProfileFieldAccess.READ_WRITE)) + assertThat(nickname.source, Matchers.`is`(ProfileFieldSource.USER)) + assertThat(nickname.reason, Matchers.nullValue()) + + assertThat(policy["/created_at"]!!.source, Matchers.`is`(ProfileFieldSource.SYSTEM)) + } + + @Test + public fun `getUserProfile should map unknown policy values to null without failing`() { + mockAPI.willReturnUserProfileWithUnknownPolicyValues() + + val policy = client.getUserProfile().execute().profilePolicy!!["/given_name"]!! + + assertThat(policy.access, Matchers.nullValue()) + assertThat(policy.source, Matchers.nullValue()) + assertThat(policy.reason, Matchers.`is`("some_new_reason")) + } + + @Test + public fun `getUserProfile should return null profilePolicy when it is not in the response`() { + mockAPI.willReturnUserProfileWithoutPolicy() + + val profile = client.getUserProfile(GetUserProfileOptions(listOf("given_name", "picture"))) + .execute() + + assertThat(profile.givenName, Matchers.`is`("Jane")) + assertThat(profile.profilePolicy, Matchers.nullValue()) + } + + @Test + public fun `getUserProfile should send Bearer authorization and Auth0-Client headers`() { + mockAPI.willReturnUserProfile() + + client.getUserProfile().execute() + + val request = mockAPI.takeRequest() + assertThat(request.getHeader("Authorization"), Matchers.`is`("Bearer $ACCESS_TOKEN")) + assertThat(request.getHeader("Auth0-Client"), Matchers.notNullValue()) + assertThat(request.getHeader("DPoP"), Matchers.nullValue()) + } + + @Test + public fun `getUserProfile should send DPoP authorization and proof when DPoP is enabled`() { + whenever(mockKeyStore.hasKeyPair()).thenReturn(true) + whenever(mockKeyStore.getKeyPair()).thenReturn(Pair(FakeECPrivateKey(), FakeECPublicKey())) + mockAPI.willReturnUserProfile() + + MyAccountAPIClient(auth0, ACCESS_TOKEN).useDPoP(mockContext).getUserProfile().execute() + + val request = mockAPI.takeRequest() + assertThat(request.getHeader("Authorization"), Matchers.`is`("DPoP $ACCESS_TOKEN")) + assertThat(request.getHeader("DPoP"), Matchers.notNullValue()) + } + + @Test + public fun `getUserProfile should return the profile through the callback`() { + mockAPI.willReturnUserProfile() + val callback = MockMyAccountCallback() + + client.getUserProfile().start(callback) + + awaitCallback(callback) + assertThat(callback.getPayload()!!.givenName, Matchers.`is`("Jane")) + assertThat(callback.getError(), Matchers.nullValue()) + } + + @Test + public fun `getUserProfile should return the profile through await`(): Unit = runTest { + mockAPI.willReturnUserProfile() + + val profile = client.getUserProfile().await() + + assertThat(profile.givenName, Matchers.`is`("Jane")) + } + + // updateUserProfile + + @Test + public fun `updateUserProfile should send a PATCH request with the set root fields only`() { + mockAPI.willReturnUserProfile() + + client.updateUserProfile( + UpdateUserProfileRequest(givenName = "Jane", nickname = "jane") + ).execute() + + val request = mockAPI.takeRequest() + assertThat(request.method, Matchers.`is`("PATCH")) + assertThat(request.path, Matchers.`is`("/me/v1/profile")) + val body = bodyFromRequest(request) + assertThat(body, Matchers.`is`(mapOf("given_name" to "Jane", "nickname" to "jane"))) + } + + @Test + public fun `updateUserProfile should send every root field when set`() { + mockAPI.willReturnUserProfile() + + client.updateUserProfile( + UpdateUserProfileRequest( + givenName = "Jane", + familyName = "Doe", + name = "Jane Doe", + nickname = "jane", + picture = "https://example.com/jane.png" + ) + ).execute() + + val body = bodyFromRequest(mockAPI.takeRequest()) + assertThat( + body, Matchers.`is`( + mapOf( + "given_name" to "Jane", + "family_name" to "Doe", + "name" to "Jane Doe", + "nickname" to "jane", + "picture" to "https://example.com/jane.png" + ) + ) + ) + } + + @Test + public fun `updateUserProfile should send an empty body for an empty request`() { + mockAPI.willReturnUserProfile() + + client.updateUserProfile(UpdateUserProfileRequest()).execute() + + val request = mockAPI.takeRequest() + assertThat(request.method, Matchers.`is`("PATCH")) + assertThat(request.body.readUtf8(), Matchers.`is`("{}")) + } + + @Test + public fun `updateUserProfile should return the full profile`() { + mockAPI.willReturnUserProfile() + + val profile = client.updateUserProfile(UpdateUserProfileRequest(givenName = "Jane")) + .execute() + + assertThat(profile.email, Matchers.`is`("jane@example.com")) + assertThat(profile.profilePolicy!!.size, Matchers.`is`(3)) + } + + @Test + public fun `updateUserProfile should send Bearer authorization header`() { + mockAPI.willReturnUserProfile() + + client.updateUserProfile(UpdateUserProfileRequest()).execute() + + val request = mockAPI.takeRequest() + assertThat(request.getHeader("Authorization"), Matchers.`is`("Bearer $ACCESS_TOKEN")) + assertThat(request.getHeader("DPoP"), Matchers.nullValue()) + } + + @Test + public fun `updateUserProfile should send DPoP authorization and proof when DPoP is enabled`() { + whenever(mockKeyStore.hasKeyPair()).thenReturn(true) + whenever(mockKeyStore.getKeyPair()).thenReturn(Pair(FakeECPrivateKey(), FakeECPublicKey())) + mockAPI.willReturnUserProfile() + + MyAccountAPIClient(auth0, ACCESS_TOKEN).useDPoP(mockContext) + .updateUserProfile(UpdateUserProfileRequest(givenName = "Jane")) + .execute() + + val request = mockAPI.takeRequest() + assertThat(request.getHeader("Authorization"), Matchers.`is`("DPoP $ACCESS_TOKEN")) + assertThat(request.getHeader("DPoP"), Matchers.notNullValue()) + assertThat(request.body.readUtf8(), Matchers.`is`("{\"given_name\":\"Jane\"}")) + } + + @Test + public fun `updateUserProfile should return the profile through await`(): Unit = runTest { + mockAPI.willReturnUserProfile() + + val profile = client.updateUserProfile(UpdateUserProfileRequest(givenName = "Jane")).await() + + assertThat(profile.givenName, Matchers.`is`("Jane")) + } + + // Errors + + @Test + public fun `getUserProfile should surface the feature not enabled error`() { + mockAPI.willReturnFeatureNotEnabledError() + + val error = executeForError { client.getUserProfile().execute() } + + assertThat(error.statusCode, Matchers.`is`(403)) + assertThat(error.getCode(), Matchers.`is`("Not Entitled")) + assertThat(error.type, Matchers.`is`("https://auth0.com/api-errors/A0E-403-0003")) + assertThat(error.detail, Matchers.`is`("This feature is currently not enabled")) + } + + @Test + public fun `updateUserProfile should surface the feature not enabled error`() { + mockAPI.willReturnFeatureNotEnabledError() + + val error = executeForError { + client.updateUserProfile(UpdateUserProfileRequest(givenName = "Jane")).execute() + } + + assertThat(error.statusCode, Matchers.`is`(403)) + assertThat(error.getCode(), Matchers.`is`("Not Entitled")) + } + + @Test + public fun `getUserProfile should surface errors through the callback`() { + mockAPI.willReturnFeatureNotEnabledError() + val callback = MockMyAccountCallback() + + client.getUserProfile().start(callback) + + awaitCallback(callback) + assertThat(callback.getError()!!.statusCode, Matchers.`is`(403)) + assertThat(callback.getPayload(), Matchers.nullValue()) + } + + @Test + public fun `updateUserProfile should surface errors through await`(): Unit = runTest { + mockAPI.willReturnFeatureNotEnabledError() + + val error = try { + client.updateUserProfile(UpdateUserProfileRequest()).await() + null + } catch (e: MyAccountException) { + e + } + + assertThat(error!!.statusCode, Matchers.`is`(403)) + } + + @Test + public fun `updateUserProfile should expose validation errors on 400`() { + mockAPI.willReturnProfileValidationError() + + val error = executeForError { + client.updateUserProfile(UpdateUserProfileRequest(givenName = "Jane")).execute() + } + + assertThat(error.statusCode, Matchers.`is`(400)) + val validationError = error.validationErrors!!.single() + assertThat(validationError.pointer, Matchers.`is`("/given_name")) + assertThat(validationError.source, Matchers.`is`("managed_by_identity_provider")) + assertThat( + validationError.detail, + Matchers.`is`("given_name is managed by the identity provider") + ) + } + + @Test + public fun `getUserProfile should surface 401 errors`() { + mockAPI.willReturnUnauthorizedError() + + val error = executeForError { client.getUserProfile().execute() } + + assertThat(error.statusCode, Matchers.`is`(401)) + assertThat(error.title, Matchers.`is`("Unauthorized")) + } + + @Test + public fun `updateUserProfile should surface 403 errors`() { + mockAPI.willReturnForbiddenError() + + val error = executeForError { + client.updateUserProfile(UpdateUserProfileRequest(givenName = "Jane")).execute() + } + + assertThat(error.statusCode, Matchers.`is`(403)) + assertThat(error.detail, Matchers.`is`("You do not have permission to perform this operation")) + } + + @Test + public fun `getUserProfile should surface network errors`() { + mockAPI.shutdown() + + val error = executeForError { client.getUserProfile().execute() } + + assertThat(error.isNetworkError, Matchers.`is`(true)) + } + + private fun executeForError(block: () -> Unit): MyAccountException { + try { + block() + } catch (e: MyAccountException) { + return e + } + throw AssertionError("Expected a MyAccountException") + } + + // The main looper can only be driven from the test thread, so poll here instead of Awaitility. + private fun awaitCallback(callback: MockMyAccountCallback<*>) { + val deadline = System.currentTimeMillis() + 5_000 + while (callback.getPayload() == null && callback.getError() == null) { + if (System.currentTimeMillis() > deadline) { + throw AssertionError("Callback was not invoked") + } + ShadowLooper.idleMainLooper() + Thread.sleep(10) + } + } + + private fun bodyFromRequest(request: RecordedRequest): Map { + val mapType = object : TypeToken>() {}.type + return Gson().fromJson(request.body.readUtf8(), mapType) + } + + private val auth0: Auth0 + get() { + val auth0 = Auth0.getInstance(CLIENT_ID, mockAPI.domain, mockAPI.domain) + auth0.networkingClient = testClient + return auth0 + } + + private companion object { + private const val CLIENT_ID = "CLIENTID" + private const val ACCESS_TOKEN = "accessToken" + } +} 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 d4905cb41..290400f6f 100644 --- a/auth0/src/test/java/com/auth0/android/util/MyAccountAPIMockServer.kt +++ b/auth0/src/test/java/com/auth0/android/util/MyAccountAPIMockServer.kt @@ -193,6 +193,105 @@ internal class MyAccountAPIMockServer : APIMockServer() { return this } + fun willReturnUserProfile(): MyAccountAPIMockServer { + val json = """ + { + "user_id": "auth0|123", + "given_name": "Jane", + "family_name": "Doe", + "name": "Jane Doe", + "nickname": "jane", + "picture": "https://example.com/jane.png", + "email": "jane@example.com", + "email_verified": true, + "phone_number": "+15555550100", + "phone_verified": false, + "username": "janedoe", + "created_at": "2026-01-01T00:00:00.000Z", + "updated_at": "2026-02-01T00:00:00.000Z", + "profile_policy": { + "/given_name": { + "access": "read_only", + "source": "idp", + "reason": "managed_by_identity_provider" + }, + "/nickname": { + "access": "read_write", + "source": "user" + }, + "/created_at": { + "access": "read_only", + "source": "system", + "reason": "system_managed" + } + } + } + """.trimIndent() + server.enqueue(responseWithJSON(json, 200)) + return this + } + + fun willReturnUserProfileWithoutPolicy(): MyAccountAPIMockServer { + val json = """ + { + "given_name": "Jane", + "picture": "https://example.com/jane.png" + } + """.trimIndent() + server.enqueue(responseWithJSON(json, 200)) + return this + } + + fun willReturnUserProfileWithUnknownPolicyValues(): MyAccountAPIMockServer { + val json = """ + { + "given_name": "Jane", + "profile_policy": { + "/given_name": { + "access": "write_only", + "source": "partner", + "reason": "some_new_reason" + } + } + } + """.trimIndent() + server.enqueue(responseWithJSON(json, 200)) + return this + } + + fun willReturnFeatureNotEnabledError(): MyAccountAPIMockServer { + val responseBody = """ + { + "type": "https://auth0.com/api-errors/A0E-403-0003", + "status": 403, + "title": "Not Entitled", + "detail": "This feature is currently not enabled" + } + """.trimIndent() + server.enqueue(responseWithJSON(responseBody, 403)) + return this + } + + fun willReturnProfileValidationError(): MyAccountAPIMockServer { + val responseBody = """ + { + "type": "https://auth0.com/api-errors/A0E-400-0008", + "status": 400, + "title": "read_only_field", + "detail": "The field '/given_name' is read-only because it is managed by the identity provider.", + "validation_errors": [ + { + "detail": "given_name is managed by the identity provider", + "pointer": "/given_name", + "source": "managed_by_identity_provider" + } + ] + } + """.trimIndent() + server.enqueue(responseWithJSON(responseBody, 400)) + return this + } + private companion object { private const val SESSION_ID = "SESSION_ID" private const val CHALLENGE = "CHALLENGE" diff --git a/examples/my-account-api.md b/examples/my-account-api.md index 03068c1f2..5b1bdff22 100644 --- a/examples/my-account-api.md +++ b/examples/my-account-api.md @@ -707,3 +707,122 @@ myAccountClient.updateAuthenticationMethodById("{Authentication_Id}", "{Name}") }); ``` + +### Get the User Profile +**Scopes required:** `read:me:profile` + +Retrieves the current user's profile. Each entry in `profilePolicy` describes whether the user can edit a field (`access`), where its value comes from (`source`), and why it is read-only (`reason`), so you can render an editable or read-only UI without checking the connection type. Entries are keyed by a JSON Pointer to the field, e.g. `/given_name`. + +**Prerequisites:** + +- The My Account profile endpoints must be enabled for your tenant. Until they are, requests fail with a `MyAccountException` with status code `403`: usually `A0E-403-0002` (Insufficient Scope), because the profile scopes can't be granted, or `A0E-403-0003` (Not Entitled) for a token issued before the feature was disabled. +- Grant the `read:me:profile` scope to your application under Auth0 Dashboard > Applications > APIs > Auth0 My Account API > Application Access. + +```kotlin +myAccountClient.getUserProfile() + .start(object : Callback { + override fun onSuccess(result: MyAccountProfile) { + val canEditGivenName = + result.profilePolicy?.get("/given_name")?.access == ProfileFieldAccess.READ_WRITE + } + override fun onFailure(error: MyAccountException) { + print("Error: ${error.message}") + } + }) + +// Only return some fields. profilePolicy is null unless "profile_policy" is requested. +myAccountClient.getUserProfile(GetUserProfileOptions(fields = listOf("given_name", "picture", "profile_policy"))) + .start(callback) + +// Return every field except the listed ones +myAccountClient.getUserProfile(GetUserProfileOptions(fields = listOf("profile_policy"), includeFields = false)) + .start(callback) +``` +
+ Using coroutines + +```kotlin +try { + val profile = myAccountClient.getUserProfile().await() + println("Profile: $profile") +} catch (exception: MyAccountException) { + print("Error: ${exception.message}") +} +``` +
+
+ Using Java + +```java +myAccountClient.getUserProfile() + .start(new Callback() { + @Override + public void onSuccess(MyAccountProfile result) { + // The user's profile + } + @Override + public void onFailure(@NonNull MyAccountException error) { } + }); + +// Only return some fields +GetUserProfileOptions options = new GetUserProfileOptions(Arrays.asList("given_name", "picture")); +myAccountClient.getUserProfile(options).start(callback); +``` +
+ +### Update the User Profile +**Scopes required:** `update:me:profile` + +Updates the user's editable profile fields, and returns the full updated profile. + +- Properties left as `null` in `UpdateUserProfileRequest` are not sent and remain unchanged. +- An empty `UpdateUserProfileRequest()` is valid and returns the current profile. +- If a field can't be updated (for example, it is managed by the identity provider), the request fails with status code `400`, and `MyAccountException.validationErrors` describes each rejected field: `pointer` (e.g. `/given_name`), `detail`, and `source`. + +**Prerequisites:** + +The same as [Get the User Profile](#get-the-user-profile), with the `update:me:profile` scope. + +```kotlin +val request = UpdateUserProfileRequest(givenName = "Jane", nickname = "jane") +myAccountClient.updateUserProfile(request) + .start(object : Callback { + override fun onSuccess(result: MyAccountProfile) { + // The updated profile + } + override fun onFailure(error: MyAccountException) { + error.validationErrors?.forEach { + Log.e("MyApp", "${it.pointer}: ${it.detail} (${it.source})") + } + } + }) +``` +
+ Using coroutines + +```kotlin +try { + val profile = myAccountClient.updateUserProfile(UpdateUserProfileRequest(givenName = "Jane")) + .await() + println("Updated profile: $profile") +} catch (exception: MyAccountException) { + print("Error: ${exception.message}") +} +``` +
+
+ Using Java + +```java +UpdateUserProfileRequest request = new UpdateUserProfileRequest("Jane"); +myAccountClient.updateUserProfile(request) + .start(new Callback() { + @Override + public void onSuccess(MyAccountProfile result) { + // The updated profile + } + @Override + public void onFailure(@NonNull MyAccountException error) { } + }); +``` +
From 66be27e56780fa107f099f63916858d6479c634f Mon Sep 17 00:00:00 2001 From: utkrishtS Date: Fri, 30 Oct 2026 09:22:04 +0530 Subject: [PATCH 06/23] refactor(myaccount): address review feedback Move GetUserProfileOptions and UpdateUserProfileRequest to a request package, use non-null defaults for GetUserProfileOptions, correct the profilePolicy rule in the docs and stop logging profiles in the examples. Co-Authored-By: Claude --- .../myaccount/GetUserProfileOptions.kt | 15 ----------- .../android/myaccount/MyAccountAPIClient.kt | 26 ++++++++++++------- .../request/GetUserProfileOptions.kt | 21 +++++++++++++++ .../{ => request}/UpdateUserProfileRequest.kt | 4 +-- .../auth0/android/result/MyAccountProfile.kt | 7 ++--- .../MyAccountAPIClientProfileTest.kt | 17 +++++++++--- examples/my-account-api.md | 12 ++++----- 7 files changed, 63 insertions(+), 39 deletions(-) delete mode 100644 auth0/src/main/java/com/auth0/android/myaccount/GetUserProfileOptions.kt create mode 100644 auth0/src/main/java/com/auth0/android/myaccount/request/GetUserProfileOptions.kt rename auth0/src/main/java/com/auth0/android/myaccount/{ => request}/UpdateUserProfileRequest.kt (82%) diff --git a/auth0/src/main/java/com/auth0/android/myaccount/GetUserProfileOptions.kt b/auth0/src/main/java/com/auth0/android/myaccount/GetUserProfileOptions.kt deleted file mode 100644 index acf51740e..000000000 --- a/auth0/src/main/java/com/auth0/android/myaccount/GetUserProfileOptions.kt +++ /dev/null @@ -1,15 +0,0 @@ -package com.auth0.android.myaccount - -/** - * Options to control which fields are returned by [MyAccountAPIClient.getUserProfile]. - * - * @param fields the fields to include in (or exclude from) the response, e.g. - * `listOf("given_name", "picture", "profile_policy")`. When set and `profile_policy` is not - * included, the returned profile's `profilePolicy` will be null. - * @param includeFields whether [fields] are included (`true`) or excluded (`false`) from the - * response. Only sent when [fields] is not empty. Defaults to inclusion on the server. - */ -public data class GetUserProfileOptions @JvmOverloads constructor( - public val fields: List? = null, - public val includeFields: Boolean? = null -) 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 3f8d161d6..8dca5dc60 100644 --- a/auth0/src/main/java/com/auth0/android/myaccount/MyAccountAPIClient.kt +++ b/auth0/src/main/java/com/auth0/android/myaccount/MyAccountAPIClient.kt @@ -8,6 +8,8 @@ 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.myaccount.request.GetUserProfileOptions +import com.auth0.android.myaccount.request.UpdateUserProfileRequest import com.auth0.android.request.ErrorAdapter import com.auth0.android.request.JsonAdapter import com.auth0.android.request.PublicKeyCredentials @@ -860,30 +862,34 @@ public class MyAccountAPIClient @VisibleForTesting(otherwise = VisibleForTesting * apiClient.getUserProfile() * .start(object : Callback { * override fun onSuccess(result: MyAccountProfile) { - * Log.d("MyApp", "Profile: $result") + * val canEditGivenName = + * result.profilePolicy?.get("/given_name")?.access == ProfileFieldAccess.READ_WRITE * } * override fun onFailure(error: MyAccountException) { * Log.e("MyApp", "Failed with: ${error.message}") * } * }) * ``` - * When [GetUserProfileOptions.fields] is set and does not include `profile_policy`, - * the returned profile's `profilePolicy` will be null. The keys of `profilePolicy` are - * JSON Pointers to the fields, e.g. `/given_name`. + * The keys of `profilePolicy` are JSON Pointers to the fields, e.g. `/given_name`. It is only + * present when `profile_policy` is part of the response: with [GetUserProfileOptions.fields] + * set and [GetUserProfileOptions.includeFields] `true`, only if `profile_policy` is listed; + * with `includeFields` `false`, unless `profile_policy` is listed. * * @param options optional selection of the fields to return. * @return a request that will yield the user's profile. */ @JvmOverloads public fun getUserProfile( - options: GetUserProfileOptions? = null + options: GetUserProfileOptions = GetUserProfileOptions() ): Request { val url = getDomainUrlBuilder().apply { addPathSegment(PROFILE) - val fields = options?.fields - if (!fields.isNullOrEmpty()) { - addQueryParameter(FIELDS_KEY, fields.joinToString(",")) - options.includeFields?.let { addQueryParameter(INCLUDE_FIELDS_KEY, it.toString()) } + if (options.fields.isNotEmpty()) { + addQueryParameter(FIELDS_KEY, options.fields.joinToString(",")) + // The server includes the listed fields by default, so only send the exclusion. + if (!options.includeFields) { + addQueryParameter(INCLUDE_FIELDS_KEY, "false") + } } }.build() return factory.get( @@ -911,7 +917,7 @@ public class MyAccountAPIClient @VisibleForTesting(otherwise = VisibleForTesting * apiClient.updateUserProfile(request) * .start(object : Callback { * override fun onSuccess(result: MyAccountProfile) { - * Log.d("MyApp", "Updated profile: $result") + * Log.d("MyApp", "Profile updated") * } * override fun onFailure(error: MyAccountException) { * // On 400, error.validationErrors describes each rejected field diff --git a/auth0/src/main/java/com/auth0/android/myaccount/request/GetUserProfileOptions.kt b/auth0/src/main/java/com/auth0/android/myaccount/request/GetUserProfileOptions.kt new file mode 100644 index 000000000..ed8bdc7a7 --- /dev/null +++ b/auth0/src/main/java/com/auth0/android/myaccount/request/GetUserProfileOptions.kt @@ -0,0 +1,21 @@ +package com.auth0.android.myaccount.request + +/** + * Options to control which fields are returned by + * [com.auth0.android.myaccount.MyAccountAPIClient.getUserProfile]. + * + * @param fields the fields to include in, or exclude from, the response, e.g. + * `listOf("given_name", "picture", "profile_policy")`. When empty, the default set of fields is + * returned. + * @param includeFields whether [fields] are included (`true`, the default) or excluded (`false`) + * from the response. Ignored when [fields] is empty. + * + * The returned profile's `profilePolicy` is only present when `profile_policy` is part of the + * response: + * - when [includeFields] is `true` and [fields] is not empty, only if [fields] contains `profile_policy` + * - when [includeFields] is `false`, unless [fields] contains `profile_policy` + */ +public data class GetUserProfileOptions @JvmOverloads constructor( + public val fields: List = emptyList(), + public val includeFields: Boolean = true +) diff --git a/auth0/src/main/java/com/auth0/android/myaccount/UpdateUserProfileRequest.kt b/auth0/src/main/java/com/auth0/android/myaccount/request/UpdateUserProfileRequest.kt similarity index 82% rename from auth0/src/main/java/com/auth0/android/myaccount/UpdateUserProfileRequest.kt rename to auth0/src/main/java/com/auth0/android/myaccount/request/UpdateUserProfileRequest.kt index 370146209..95281c852 100644 --- a/auth0/src/main/java/com/auth0/android/myaccount/UpdateUserProfileRequest.kt +++ b/auth0/src/main/java/com/auth0/android/myaccount/request/UpdateUserProfileRequest.kt @@ -1,7 +1,7 @@ -package com.auth0.android.myaccount +package com.auth0.android.myaccount.request /** - * The changes to apply with [MyAccountAPIClient.updateUserProfile]. + * The changes to apply with [com.auth0.android.myaccount.MyAccountAPIClient.updateUserProfile]. * * Properties left as `null` are not sent and remain unchanged. A request with every property * `null` is valid and returns the current profile. diff --git a/auth0/src/main/java/com/auth0/android/result/MyAccountProfile.kt b/auth0/src/main/java/com/auth0/android/result/MyAccountProfile.kt index 30a2ecf3c..91ec0edef 100644 --- a/auth0/src/main/java/com/auth0/android/result/MyAccountProfile.kt +++ b/auth0/src/main/java/com/auth0/android/result/MyAccountProfile.kt @@ -13,10 +13,10 @@ import java.lang.reflect.Type * * Every property is nullable: the server omits fields that are not set for the user, and * any field can be trimmed from the response by using - * [com.auth0.android.myaccount.GetUserProfileOptions.fields]. + * [com.auth0.android.myaccount.request.GetUserProfileOptions.fields]. * * [userId], [emailVerified] and [phoneVerified] are read-only: they can't be changed with - * [com.auth0.android.myaccount.UpdateUserProfileRequest]. + * [com.auth0.android.myaccount.request.UpdateUserProfileRequest]. */ public data class MyAccountProfile( @SerializedName("user_id") @@ -48,7 +48,8 @@ public data class MyAccountProfile( /** * Describes, per field, whether it can be edited by the user and why. Keyed by a JSON * Pointer to the field, e.g. `/given_name`. - * Absent when `profile_policy` is excluded from the requested fields. + * Absent when `profile_policy` is not part of the response, see + * [com.auth0.android.myaccount.request.GetUserProfileOptions]. */ @SerializedName("profile_policy") public val profilePolicy: Map? = null diff --git a/auth0/src/test/java/com/auth0/android/myaccount/MyAccountAPIClientProfileTest.kt b/auth0/src/test/java/com/auth0/android/myaccount/MyAccountAPIClientProfileTest.kt index 429d444c3..92d37e987 100644 --- a/auth0/src/test/java/com/auth0/android/myaccount/MyAccountAPIClientProfileTest.kt +++ b/auth0/src/test/java/com/auth0/android/myaccount/MyAccountAPIClientProfileTest.kt @@ -6,6 +6,8 @@ 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.myaccount.request.GetUserProfileOptions +import com.auth0.android.myaccount.request.UpdateUserProfileRequest import com.auth0.android.result.MyAccountProfile import com.auth0.android.result.ProfileFieldAccess import com.auth0.android.result.ProfileFieldSource @@ -76,7 +78,7 @@ public class MyAccountAPIClientProfileTest { } @Test - public fun `getUserProfile should send comma-joined fields without include_fields when not set`() { + public fun `getUserProfile should send comma-joined fields without include_fields by default`() { mockAPI.willReturnUserProfile() client.getUserProfile(GetUserProfileOptions(fields = listOf("given_name", "picture"))) @@ -88,14 +90,23 @@ public class MyAccountAPIClientProfileTest { } @Test - public fun `getUserProfile should send include_fields true`() { + public fun `getUserProfile should not send include_fields when it is true, the server default`() { mockAPI.willReturnUserProfile() client.getUserProfile(GetUserProfileOptions(listOf("given_name"), true)).execute() val url = mockAPI.takeRequest().requestUrl!! assertThat(url.queryParameter("fields"), Matchers.`is`("given_name")) - assertThat(url.queryParameter("include_fields"), Matchers.`is`("true")) + assertThat(url.queryParameterNames.contains("include_fields"), Matchers.`is`(false)) + } + + @Test + public fun `getUserProfile should not send include_fields false when fields is empty`() { + mockAPI.willReturnUserProfile() + + client.getUserProfile(GetUserProfileOptions(includeFields = false)).execute() + + assertThat(mockAPI.takeRequest().path, Matchers.`is`("/me/v1/profile")) } @Test diff --git a/examples/my-account-api.md b/examples/my-account-api.md index 5b1bdff22..5edeb4c0d 100644 --- a/examples/my-account-api.md +++ b/examples/my-account-api.md @@ -711,7 +711,7 @@ myAccountClient.updateAuthenticationMethodById("{Authentication_Id}", "{Name}") ### Get the User Profile **Scopes required:** `read:me:profile` -Retrieves the current user's profile. Each entry in `profilePolicy` describes whether the user can edit a field (`access`), where its value comes from (`source`), and why it is read-only (`reason`), so you can render an editable or read-only UI without checking the connection type. Entries are keyed by a JSON Pointer to the field, e.g. `/given_name`. +Retrieves the current user's profile. Each entry in `profilePolicy` describes whether the user can edit a field (`access`), where its value comes from (`source`), and why it is read-only (`reason`), so you can render an editable or read-only UI without checking the connection type. Entries are keyed by a JSON Pointer to the field, e.g. `/given_name`. `GetUserProfileOptions` and `UpdateUserProfileRequest` are in the `com.auth0.android.myaccount.request` package. **Prerequisites:** @@ -730,11 +730,11 @@ myAccountClient.getUserProfile() } }) -// Only return some fields. profilePolicy is null unless "profile_policy" is requested. +// Only return the listed fields. profilePolicy is only present if "profile_policy" is listed. myAccountClient.getUserProfile(GetUserProfileOptions(fields = listOf("given_name", "picture", "profile_policy"))) .start(callback) -// Return every field except the listed ones +// Return every field except the listed ones. profilePolicy is present unless it is listed. myAccountClient.getUserProfile(GetUserProfileOptions(fields = listOf("profile_policy"), includeFields = false)) .start(callback) ``` @@ -744,7 +744,7 @@ myAccountClient.getUserProfile(GetUserProfileOptions(fields = listOf("profile_po ```kotlin try { val profile = myAccountClient.getUserProfile().await() - println("Profile: $profile") + val givenName = profile.givenName } catch (exception: MyAccountException) { print("Error: ${exception.message}") } @@ -802,9 +802,9 @@ myAccountClient.updateUserProfile(request) ```kotlin try { - val profile = myAccountClient.updateUserProfile(UpdateUserProfileRequest(givenName = "Jane")) + val updated = myAccountClient.updateUserProfile(UpdateUserProfileRequest(givenName = "Jane")) .await() - println("Updated profile: $profile") + val givenName = updated.givenName } catch (exception: MyAccountException) { print("Error: ${exception.message}") } From ccf025ba8e7d348cdb7c0265b3d73118a7ca5899 Mon Sep 17 00:00:00 2001 From: utkrishtS Date: Fri, 30 Oct 2026 11:37:12 +0530 Subject: [PATCH 07/23] refactor(myaccount): address review feedback on defaults and EA docs Default profilePolicy to an empty map and mark the profile APIs as Early Access in the KDoc and examples. Co-Authored-By: Claude --- .../android/myaccount/MyAccountAPIClient.kt | 18 +++++++++++++++--- .../myaccount/request/GetUserProfileOptions.kt | 2 +- .../auth0/android/result/MyAccountProfile.kt | 4 ++-- .../myaccount/MyAccountAPIClientProfileTest.kt | 10 +++++----- examples/my-account-api.md | 14 ++++++++++---- 5 files changed, 33 insertions(+), 15 deletions(-) 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 8dca5dc60..1a0c58a86 100644 --- a/auth0/src/main/java/com/auth0/android/myaccount/MyAccountAPIClient.kt +++ b/auth0/src/main/java/com/auth0/android/myaccount/MyAccountAPIClient.kt @@ -850,6 +850,12 @@ public class MyAccountAPIClient @VisibleForTesting(otherwise = VisibleForTesting * Retrieves the current user's profile, including a `profile_policy` descriptor for each * field that tells whether it can be edited by the user and why. * + * ## 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:profile` * @@ -863,15 +869,15 @@ public class MyAccountAPIClient @VisibleForTesting(otherwise = VisibleForTesting * .start(object : Callback { * override fun onSuccess(result: MyAccountProfile) { * val canEditGivenName = - * result.profilePolicy?.get("/given_name")?.access == ProfileFieldAccess.READ_WRITE + * result.profilePolicy["/given_name"]?.access == ProfileFieldAccess.READ_WRITE * } * override fun onFailure(error: MyAccountException) { * Log.e("MyApp", "Failed with: ${error.message}") * } * }) * ``` - * The keys of `profilePolicy` are JSON Pointers to the fields, e.g. `/given_name`. It is only - * present when `profile_policy` is part of the response: with [GetUserProfileOptions.fields] + * The keys of `profilePolicy` are JSON Pointers to the fields, e.g. `/given_name`. It is empty + * unless `profile_policy` is part of the response: with [GetUserProfileOptions.fields] * set and [GetUserProfileOptions.includeFields] `true`, only if `profile_policy` is listed; * with `includeFields` `false`, unless `profile_policy` is listed. * @@ -904,6 +910,12 @@ public class MyAccountAPIClient @VisibleForTesting(otherwise = VisibleForTesting * Updates the current user's editable profile fields. * Fields left as `null` in the [request] remain unchanged. * + * ## 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 * `update:me:profile` * diff --git a/auth0/src/main/java/com/auth0/android/myaccount/request/GetUserProfileOptions.kt b/auth0/src/main/java/com/auth0/android/myaccount/request/GetUserProfileOptions.kt index ed8bdc7a7..c2924efdc 100644 --- a/auth0/src/main/java/com/auth0/android/myaccount/request/GetUserProfileOptions.kt +++ b/auth0/src/main/java/com/auth0/android/myaccount/request/GetUserProfileOptions.kt @@ -10,7 +10,7 @@ package com.auth0.android.myaccount.request * @param includeFields whether [fields] are included (`true`, the default) or excluded (`false`) * from the response. Ignored when [fields] is empty. * - * The returned profile's `profilePolicy` is only present when `profile_policy` is part of the + * The returned profile's `profilePolicy` is empty unless `profile_policy` is part of the * response: * - when [includeFields] is `true` and [fields] is not empty, only if [fields] contains `profile_policy` * - when [includeFields] is `false`, unless [fields] contains `profile_policy` diff --git a/auth0/src/main/java/com/auth0/android/result/MyAccountProfile.kt b/auth0/src/main/java/com/auth0/android/result/MyAccountProfile.kt index 91ec0edef..5e205720b 100644 --- a/auth0/src/main/java/com/auth0/android/result/MyAccountProfile.kt +++ b/auth0/src/main/java/com/auth0/android/result/MyAccountProfile.kt @@ -48,11 +48,11 @@ public data class MyAccountProfile( /** * Describes, per field, whether it can be edited by the user and why. Keyed by a JSON * Pointer to the field, e.g. `/given_name`. - * Absent when `profile_policy` is not part of the response, see + * Empty when `profile_policy` is not part of the response, see * [com.auth0.android.myaccount.request.GetUserProfileOptions]. */ @SerializedName("profile_policy") - public val profilePolicy: Map? = null + public val profilePolicy: Map = emptyMap() ) /** diff --git a/auth0/src/test/java/com/auth0/android/myaccount/MyAccountAPIClientProfileTest.kt b/auth0/src/test/java/com/auth0/android/myaccount/MyAccountAPIClientProfileTest.kt index 92d37e987..a4bb8a4ae 100644 --- a/auth0/src/test/java/com/auth0/android/myaccount/MyAccountAPIClientProfileTest.kt +++ b/auth0/src/test/java/com/auth0/android/myaccount/MyAccountAPIClientProfileTest.kt @@ -145,7 +145,7 @@ public class MyAccountAPIClientProfileTest { public fun `getUserProfile should parse profile_policy`() { mockAPI.willReturnUserProfile() - val policy = client.getUserProfile().execute().profilePolicy!! + val policy = client.getUserProfile().execute().profilePolicy val givenName = policy["/given_name"]!! assertThat(givenName.access, Matchers.`is`(ProfileFieldAccess.READ_ONLY)) @@ -164,7 +164,7 @@ public class MyAccountAPIClientProfileTest { public fun `getUserProfile should map unknown policy values to null without failing`() { mockAPI.willReturnUserProfileWithUnknownPolicyValues() - val policy = client.getUserProfile().execute().profilePolicy!!["/given_name"]!! + val policy = client.getUserProfile().execute().profilePolicy["/given_name"]!! assertThat(policy.access, Matchers.nullValue()) assertThat(policy.source, Matchers.nullValue()) @@ -172,14 +172,14 @@ public class MyAccountAPIClientProfileTest { } @Test - public fun `getUserProfile should return null profilePolicy when it is not in the response`() { + public fun `getUserProfile should return an empty profilePolicy when it is not in the response`() { mockAPI.willReturnUserProfileWithoutPolicy() val profile = client.getUserProfile(GetUserProfileOptions(listOf("given_name", "picture"))) .execute() assertThat(profile.givenName, Matchers.`is`("Jane")) - assertThat(profile.profilePolicy, Matchers.nullValue()) + assertThat(profile.profilePolicy.isEmpty(), Matchers.`is`(true)) } @Test @@ -292,7 +292,7 @@ public class MyAccountAPIClientProfileTest { .execute() assertThat(profile.email, Matchers.`is`("jane@example.com")) - assertThat(profile.profilePolicy!!.size, Matchers.`is`(3)) + assertThat(profile.profilePolicy.size, Matchers.`is`(3)) } @Test diff --git a/examples/my-account-api.md b/examples/my-account-api.md index 5edeb4c0d..8e38d601e 100644 --- a/examples/my-account-api.md +++ b/examples/my-account-api.md @@ -713,9 +713,12 @@ myAccountClient.updateAuthenticationMethodById("{Authentication_Id}", "{Name}") Retrieves the current user's profile. Each entry in `profilePolicy` describes whether the user can edit a field (`access`), where its value comes from (`source`), and why it is read-only (`reason`), so you can render an editable or read-only UI without checking the connection type. Entries are keyed by a JSON Pointer to the field, e.g. `/given_name`. `GetUserProfileOptions` and `UpdateUserProfileRequest` are in the `com.auth0.android.myaccount.request` package. +> [!NOTE] +> The My Account profile endpoints are currently in [Early Access](https://auth0.com/docs/troubleshoot/product-lifecycle/product-release-stages#early-access). To request access to this feature, contact your Auth0 representative. + **Prerequisites:** -- The My Account profile endpoints must be enabled for your tenant. Until they are, requests fail with a `MyAccountException` with status code `403`: usually `A0E-403-0002` (Insufficient Scope), because the profile scopes can't be granted, or `A0E-403-0003` (Not Entitled) for a token issued before the feature was disabled. +- The profile feature must be enabled for your tenant. Until it is, requests fail with a `MyAccountException` with status code `403`: usually `A0E-403-0002` (Insufficient Scope), because the profile scopes can't be granted, or `A0E-403-0003` (Not Entitled) for a token issued before the feature was disabled. - Grant the `read:me:profile` scope to your application under Auth0 Dashboard > Applications > APIs > Auth0 My Account API > Application Access. ```kotlin @@ -723,18 +726,18 @@ myAccountClient.getUserProfile() .start(object : Callback { override fun onSuccess(result: MyAccountProfile) { val canEditGivenName = - result.profilePolicy?.get("/given_name")?.access == ProfileFieldAccess.READ_WRITE + result.profilePolicy["/given_name"]?.access == ProfileFieldAccess.READ_WRITE } override fun onFailure(error: MyAccountException) { print("Error: ${error.message}") } }) -// Only return the listed fields. profilePolicy is only present if "profile_policy" is listed. +// Only return the listed fields. profilePolicy is empty unless "profile_policy" is listed. myAccountClient.getUserProfile(GetUserProfileOptions(fields = listOf("given_name", "picture", "profile_policy"))) .start(callback) -// Return every field except the listed ones. profilePolicy is present unless it is listed. +// Return every field except the listed ones. profilePolicy is empty if it is listed. myAccountClient.getUserProfile(GetUserProfileOptions(fields = listOf("profile_policy"), includeFields = false)) .start(callback) ``` @@ -775,6 +778,9 @@ myAccountClient.getUserProfile(options).start(callback); Updates the user's editable profile fields, and returns the full updated profile. +> [!NOTE] +> The My Account profile endpoints are currently in [Early Access](https://auth0.com/docs/troubleshoot/product-lifecycle/product-release-stages#early-access). To request access to this feature, contact your Auth0 representative. + - Properties left as `null` in `UpdateUserProfileRequest` are not sent and remain unchanged. - An empty `UpdateUserProfileRequest()` is valid and returns the current profile. - If a field can't be updated (for example, it is managed by the identity provider), the request fails with status code `400`, and `MyAccountException.validationErrors` describes each rejected field: `pointer` (e.g. `/given_name`), `detail`, and `source`. From 93df6a13cc7d06895c9277799a63c640074873ef Mon Sep 17 00:00:00 2001 From: utkrishtS Date: Fri, 30 Oct 2026 12:14:19 +0530 Subject: [PATCH 08/23] docs(readme): add My Account profile section Co-Authored-By: Claude --- README.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/README.md b/README.md index ce1701bce..385454082 100644 --- a/README.md +++ b/README.md @@ -335,6 +335,24 @@ WebAuthProvider.login(account) See the [full usage example](examples/experiment-center.md) for coroutine and Java samples. +### My Account API: user profile + +> [!NOTE] +> The My Account profile API is currently in Early Access. To request access to this feature, contact your Auth0 representative. + +After obtaining an access token for the My Account API, use `MyAccountAPIClient` to read the signed-in user's profile and update the fields they are allowed to edit. Each field in `profilePolicy` tells you whether it is editable and why: + +```kotlin +val client = MyAccountAPIClient(account, accessToken) + +val profile = client.getUserProfile().await() +val canEditGivenName = profile.profilePolicy["/given_name"]?.access == ProfileFieldAccess.READ_WRITE + +client.updateUserProfile(UpdateUserProfileRequest(givenName = "Jane")).await() +``` + +See the [full usage example](examples/my-account-api.md#get-the-user-profile) for callback, coroutine and Java samples. + ## Credentials Manager This library ships with two additional classes that help you manage the Credentials received during authentication. From 96c2ba2318dc48a20322b4577f1a1098a4c6e9c7 Mon Sep 17 00:00:00 2001 From: utkrishtS Date: Fri, 30 Oct 2026 12:45:44 +0530 Subject: [PATCH 09/23] Release 4.3.0 --- .version | 2 +- CHANGELOG.md | 6 ++++++ README.md | 2 +- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/.version b/.version index d87edbfc1..819113891 100644 --- a/.version +++ b/.version @@ -1 +1 @@ -4.2.1 \ No newline at end of file +4.3.0 \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 37da1b107..dfba59f26 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Change Log +## [4.3.0](https://github.com/auth0/Auth0.Android/tree/4.3.0) (2026-10-30) +[Full Changelog](https://github.com/auth0/Auth0.Android/compare/4.2.1...4.3.0) + +**Added** +- feat(myaccount): Add getUserProfile and updateUserProfile methods [\#1115](https://github.com/auth0/Auth0.Android/pull/1115) ([utkrishtsahu](https://github.com/utkrishtsahu)) + ## [4.2.1](https://github.com/auth0/Auth0.Android/tree/4.2.1) (2026-10-26) [Full Changelog](https://github.com/auth0/Auth0.Android/compare/4.2.0...4.2.1) diff --git a/README.md b/README.md index 385454082..ef42c6239 100644 --- a/README.md +++ b/README.md @@ -51,7 +51,7 @@ To install Auth0.Android with [Gradle](https://gradle.org/), simply add the foll ```gradle dependencies { - implementation 'com.auth0.android:auth0:4.2.1' + implementation 'com.auth0.android:auth0:4.3.0' } ``` From c1e6ddc137f72c604a45c26df96168be7bb6a669 Mon Sep 17 00:00:00 2001 From: nirmal-joishi-a0 <89918087+nirmal-joishi-a0@users.noreply.github.com> Date: Tue, 3 Nov 2026 10:43:35 +0530 Subject: [PATCH 10/23] chore(security): add OpenSSF Scorecard workflow (#1090) Co-authored-by: Prince Mathew <17837162+pmathew92@users.noreply.github.com> --- .github/workflows/scorecard.yml | 80 +++++++++++++++++++++++++++++++++ 1 file changed, 80 insertions(+) create mode 100644 .github/workflows/scorecard.yml diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml new file mode 100644 index 000000000..5a0aa9251 --- /dev/null +++ b/.github/workflows/scorecard.yml @@ -0,0 +1,80 @@ +name: Scorecard analysis + +# Runs OpenSSF Scorecard supply-chain analysis directly via ossf/scorecard-action +# (no composite wrapper, no cross-org dependency). All action refs are SHA-pinned. +# +# Scorecard requirements +# ────────────────────── +# • Supported triggers (per OSSF) are push and schedule on the default branch. +# pull_request is added below ONLY as a smoke test on a real runner from the PR. +# workflow_dispatch provides manual smoke tests after this workflow exists on the default branch. +# OSSF officially documents both triggers as EXPERIMENTAL ("The pull_request and +# workflow_dispatch triggers are experimental" — scorecard-action README); the docs do not +# detail how results differ on them. Because they are experimental, treat a PR run as a +# smoke signal only — this workflow is fully validated only after merge to the default branch. +# • The checkout step MUST set persist-credentials: false (required by Scorecard's +# security model to avoid leaking the GITHUB_TOKEN into the analysis environment). +# • publish_results: true publishes to the public Scorecard API and enables the +# Scorecard badge. It requires id-token: write on the job AND a push/schedule +# event — it must stay false on pull_request/workflow_dispatch. Set to 'false' to +# keep results private (id-token can then be omitted from the job permissions). + +on: + push: + branches: ['main'] + pull_request: + branches: ['main'] # EXPERIMENTAL per OSSF: smoke test only, not an authoritative result + workflow_dispatch: {} # EXPERIMENTAL per OSSF: manual re-run, available only after merge (default-branch copy) + schedule: + - cron: '30 1 * * 6' # weekly, Saturday 01:30 UTC + +permissions: read-all # default: restrict everything; the job overrides what it needs + +jobs: + analysis: + name: Scorecard analysis + runs-on: ubuntu-latest + # No event guard: pull_request and workflow_dispatch are allowed to run as an + # experimental smoke test (see the header). Keep publish_results: false on those + # events — publishing requires a push/schedule event and id-token: write. + permissions: + security-events: write # upload SARIF to the Security tab / Code Scanning dashboard + # id-token: write # OIDC token for Scorecard's public API publishing (publish_results: true) + # Uncomment this line only when you set publish_results: true + contents: read # checkout the repository + + steps: + - name: Checkout code + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false # required by Scorecard — do not remove + + - name: Run analysis + uses: ossf/scorecard-action@4eaacf0543bb3f2c246792bd56e8cdeffafb205a # v2.4.3 + with: + results_file: results.sarif + results_format: sarif + # publish_results: true publishes to the Scorecard public dataset and enables the badge. + # Requires id-token: write above and a push/schedule event. + # Set to 'true' (and enable id-token: write) to publish results to the Scorecard public dataset. + publish_results: false + + # Upload SARIF as a workflow artifact (optional — comment out to disable). + # https://docs.github.com/en/actions/advanced-guides/storing-workflow-data-as-artifacts + - name: Upload artifact + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: SARIF file + path: results.sarif + retention-days: 5 # adjust as needed + + # Upload SARIF to GitHub Code Scanning dashboard (optional — comment out to disable). + # Skipped only for fork PRs: there the GITHUB_TOKEN is read-only (no security-events: write) + # and the upload fails. The guard still runs on push/schedule and on same-repo PRs, where the + # token can write. (github.event.pull_request is null on push/schedule, so !…fork is true.) + - name: Upload to code-scanning + if: ${{ !github.event.pull_request.head.repo.fork }} + uses: github/codeql-action/upload-sarif@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0 + with: + sarif_file: results.sarif + category: scorecard From 0699d468fc8d41b7f06595793a3380dc2115d7ea Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 5 Nov 2026 10:29:16 +0530 Subject: [PATCH 11/23] chore(deps): bump github/codeql-action/upload-sarif from 4.37.0 to 4.39.1 in the codeql group (#1119) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/scorecard.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml index 5a0aa9251..ee48b922c 100644 --- a/.github/workflows/scorecard.yml +++ b/.github/workflows/scorecard.yml @@ -74,7 +74,7 @@ jobs: # token can write. (github.event.pull_request is null on push/schedule, so !…fork is true.) - name: Upload to code-scanning if: ${{ !github.event.pull_request.head.repo.fork }} - uses: github/codeql-action/upload-sarif@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0 + uses: github/codeql-action/upload-sarif@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4.39.1 with: sarif_file: results.sarif category: scorecard From 4dfff89f9b84bdf0e0b2d317157be3325a74829a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 5 Nov 2026 10:34:31 +0530 Subject: [PATCH 12/23] chore(deps): bump actions/upload-artifact from 7.0.1 to 7.0.2 (#1120) 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/scorecard.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml index ee48b922c..a7f92a7ab 100644 --- a/.github/workflows/scorecard.yml +++ b/.github/workflows/scorecard.yml @@ -62,7 +62,7 @@ jobs: # Upload SARIF as a workflow artifact (optional — comment out to disable). # https://docs.github.com/en/actions/advanced-guides/storing-workflow-data-as-artifacts - name: Upload artifact - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: SARIF file path: results.sarif From 9234203b9cc489861d20288567f17d3527e13a0d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 5 Nov 2026 10:43:40 +0530 Subject: [PATCH 13/23] chore(deps): bump ossf/scorecard-action from 2.4.3 to 2.5.0 (#1121) 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/scorecard.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml index a7f92a7ab..0ada6c957 100644 --- a/.github/workflows/scorecard.yml +++ b/.github/workflows/scorecard.yml @@ -50,7 +50,7 @@ jobs: persist-credentials: false # required by Scorecard — do not remove - name: Run analysis - uses: ossf/scorecard-action@4eaacf0543bb3f2c246792bd56e8cdeffafb205a # v2.4.3 + uses: ossf/scorecard-action@fa7117db83c52d787ae5a89f350999701505d78e # v2.5.0 with: results_file: results.sarif results_format: sarif From d031ee688287ad18b0cefc8b25aa27645ab7bf89 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 5 Nov 2026 10:52:57 +0530 Subject: [PATCH 14/23] chore(deps): bump actions/checkout from 7.0.0 to 7.0.1 (#1122) 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/scorecard.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml index 0ada6c957..6e9d1d67d 100644 --- a/.github/workflows/scorecard.yml +++ b/.github/workflows/scorecard.yml @@ -45,7 +45,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false # required by Scorecard — do not remove From c02f7c7ff58372d197fa24ac4b8d5d50994baa83 Mon Sep 17 00:00:00 2001 From: utkrishtS Date: Fri, 6 Nov 2026 10:20:32 +0530 Subject: [PATCH 15/23] docs(myaccount): mark the My Account Profile API as Beta Co-Authored-By: Claude --- README.md | 6 +++--- .../auth0/android/myaccount/MyAccountAPIClient.kt | 14 ++++++++------ .../myaccount/request/GetUserProfileOptions.kt | 2 ++ .../myaccount/request/UpdateUserProfileRequest.kt | 2 ++ .../com/auth0/android/result/MyAccountProfile.kt | 8 ++++++++ examples/my-account-api.md | 10 +++++----- 6 files changed, 28 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index ef42c6239..ba4b5746e 100644 --- a/README.md +++ b/README.md @@ -335,10 +335,10 @@ WebAuthProvider.login(account) See the [full usage example](examples/experiment-center.md) for coroutine and Java samples. -### My Account API: user profile +### My Account API: user profile [Beta] > [!NOTE] -> The My Account profile API is currently in Early Access. To request access to this feature, contact your Auth0 representative. +> The My Account profile API is currently in [Beta](https://auth0.com/docs/troubleshoot/product-lifecycle/product-release-stages#beta). It might change before General Availability, and Auth0 does not support beta functionality for production use. The feature must be enabled for your tenant. After obtaining an access token for the My Account API, use `MyAccountAPIClient` to read the signed-in user's profile and update the fields they are allowed to edit. Each field in `profilePolicy` tells you whether it is editable and why: @@ -351,7 +351,7 @@ val canEditGivenName = profile.profilePolicy["/given_name"]?.access == ProfileFi client.updateUserProfile(UpdateUserProfileRequest(givenName = "Jane")).await() ``` -See the [full usage example](examples/my-account-api.md#get-the-user-profile) for callback, coroutine and Java samples. +See the [full usage example](examples/my-account-api.md#get-the-user-profile-beta) for callback, coroutine and Java samples. ## 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 1a0c58a86..f1e732951 100644 --- a/auth0/src/main/java/com/auth0/android/myaccount/MyAccountAPIClient.kt +++ b/auth0/src/main/java/com/auth0/android/myaccount/MyAccountAPIClient.kt @@ -852,9 +852,10 @@ public class MyAccountAPIClient @VisibleForTesting(otherwise = VisibleForTesting * * ## 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. + * This feature is currently in + * [Beta](https://auth0.com/docs/troubleshoot/product-lifecycle/product-release-stages#beta). + * It might change before General Availability, and Auth0 does not support beta functionality for + * production use. The feature must be enabled for your tenant. * * ## Scopes Required * `read:me:profile` @@ -912,9 +913,10 @@ public class MyAccountAPIClient @VisibleForTesting(otherwise = VisibleForTesting * * ## 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. + * This feature is currently in + * [Beta](https://auth0.com/docs/troubleshoot/product-lifecycle/product-release-stages#beta). + * It might change before General Availability, and Auth0 does not support beta functionality for + * production use. The feature must be enabled for your tenant. * * ## Scopes Required * `update:me:profile` diff --git a/auth0/src/main/java/com/auth0/android/myaccount/request/GetUserProfileOptions.kt b/auth0/src/main/java/com/auth0/android/myaccount/request/GetUserProfileOptions.kt index c2924efdc..7f91efd70 100644 --- a/auth0/src/main/java/com/auth0/android/myaccount/request/GetUserProfileOptions.kt +++ b/auth0/src/main/java/com/auth0/android/myaccount/request/GetUserProfileOptions.kt @@ -4,6 +4,8 @@ package com.auth0.android.myaccount.request * Options to control which fields are returned by * [com.auth0.android.myaccount.MyAccountAPIClient.getUserProfile]. * + * This API is currently in [Beta](https://auth0.com/docs/troubleshoot/product-lifecycle/product-release-stages#beta) and might change before General Availability. + * * @param fields the fields to include in, or exclude from, the response, e.g. * `listOf("given_name", "picture", "profile_policy")`. When empty, the default set of fields is * returned. diff --git a/auth0/src/main/java/com/auth0/android/myaccount/request/UpdateUserProfileRequest.kt b/auth0/src/main/java/com/auth0/android/myaccount/request/UpdateUserProfileRequest.kt index 95281c852..f74cdbe13 100644 --- a/auth0/src/main/java/com/auth0/android/myaccount/request/UpdateUserProfileRequest.kt +++ b/auth0/src/main/java/com/auth0/android/myaccount/request/UpdateUserProfileRequest.kt @@ -3,6 +3,8 @@ package com.auth0.android.myaccount.request /** * The changes to apply with [com.auth0.android.myaccount.MyAccountAPIClient.updateUserProfile]. * + * This API is currently in [Beta](https://auth0.com/docs/troubleshoot/product-lifecycle/product-release-stages#beta) and might change before General Availability. + * * Properties left as `null` are not sent and remain unchanged. A request with every property * `null` is valid and returns the current profile. * diff --git a/auth0/src/main/java/com/auth0/android/result/MyAccountProfile.kt b/auth0/src/main/java/com/auth0/android/result/MyAccountProfile.kt index 5e205720b..c192726f2 100644 --- a/auth0/src/main/java/com/auth0/android/result/MyAccountProfile.kt +++ b/auth0/src/main/java/com/auth0/android/result/MyAccountProfile.kt @@ -11,6 +11,8 @@ import java.lang.reflect.Type /** * The current user's profile, as returned by the My Account API `/me/v1/profile` endpoint. * + * This API is currently in [Beta](https://auth0.com/docs/troubleshoot/product-lifecycle/product-release-stages#beta) and might change before General Availability. + * * Every property is nullable: the server omits fields that are not set for the user, and * any field can be trimmed from the response by using * [com.auth0.android.myaccount.request.GetUserProfileOptions.fields]. @@ -57,6 +59,8 @@ public data class MyAccountProfile( /** * Describes whether a profile field can be edited by the user and why. + * + * This API is currently in [Beta](https://auth0.com/docs/troubleshoot/product-lifecycle/product-release-stages#beta) and might change before General Availability. */ @JsonAdapter(ProfileFieldPolicy.Deserializer::class) public data class ProfileFieldPolicy( @@ -100,6 +104,8 @@ public data class ProfileFieldPolicy( /** * Whether a profile field can be edited by the user. + * + * This API is currently in [Beta](https://auth0.com/docs/troubleshoot/product-lifecycle/product-release-stages#beta) and might change before General Availability. */ public enum class ProfileFieldAccess(public val value: String) { READ_ONLY("read_only"), @@ -112,6 +118,8 @@ public enum class ProfileFieldAccess(public val value: String) { /** * Where a profile field's value comes from. + * + * This API is currently in [Beta](https://auth0.com/docs/troubleshoot/product-lifecycle/product-release-stages#beta) and might change before General Availability. */ public enum class ProfileFieldSource(public val value: String) { USER("user"), diff --git a/examples/my-account-api.md b/examples/my-account-api.md index 8e38d601e..22c6dab83 100644 --- a/examples/my-account-api.md +++ b/examples/my-account-api.md @@ -708,13 +708,13 @@ myAccountClient.updateAuthenticationMethodById("{Authentication_Id}", "{Name}") ``` -### Get the User Profile +### Get the User Profile [Beta] **Scopes required:** `read:me:profile` Retrieves the current user's profile. Each entry in `profilePolicy` describes whether the user can edit a field (`access`), where its value comes from (`source`), and why it is read-only (`reason`), so you can render an editable or read-only UI without checking the connection type. Entries are keyed by a JSON Pointer to the field, e.g. `/given_name`. `GetUserProfileOptions` and `UpdateUserProfileRequest` are in the `com.auth0.android.myaccount.request` package. > [!NOTE] -> The My Account profile endpoints are currently in [Early Access](https://auth0.com/docs/troubleshoot/product-lifecycle/product-release-stages#early-access). To request access to this feature, contact your Auth0 representative. +> The My Account profile API is currently in [Beta](https://auth0.com/docs/troubleshoot/product-lifecycle/product-release-stages#beta). It might change before General Availability, and Auth0 does not support beta functionality for production use. The feature must be enabled for your tenant. **Prerequisites:** @@ -773,13 +773,13 @@ myAccountClient.getUserProfile(options).start(callback); ``` -### Update the User Profile +### Update the User Profile [Beta] **Scopes required:** `update:me:profile` Updates the user's editable profile fields, and returns the full updated profile. > [!NOTE] -> The My Account profile endpoints are currently in [Early Access](https://auth0.com/docs/troubleshoot/product-lifecycle/product-release-stages#early-access). To request access to this feature, contact your Auth0 representative. +> The My Account profile API is currently in [Beta](https://auth0.com/docs/troubleshoot/product-lifecycle/product-release-stages#beta). It might change before General Availability, and Auth0 does not support beta functionality for production use. The feature must be enabled for your tenant. - Properties left as `null` in `UpdateUserProfileRequest` are not sent and remain unchanged. - An empty `UpdateUserProfileRequest()` is valid and returns the current profile. @@ -787,7 +787,7 @@ Updates the user's editable profile fields, and returns the full updated profile **Prerequisites:** -The same as [Get the User Profile](#get-the-user-profile), with the `update:me:profile` scope. +The same as [Get the User Profile](#get-the-user-profile-beta), with the `update:me:profile` scope. ```kotlin val request = UpdateUserProfileRequest(givenName = "Jane", nickname = "jane") From b236beb49c9f449b18d0c722b9c302a7741524e3 Mon Sep 17 00:00:00 2001 From: Subhankar Maiti Date: Fri, 6 Nov 2026 13:26:25 +0530 Subject: [PATCH 16/23] fix(webauth): retry code exchange while network is still blocked, make it configurable --- EXAMPLES.md | 2 +- .../auth0/android/provider/OAuthManager.kt | 18 ++++ .../android/provider/OAuthManagerState.kt | 17 +++- .../java/com/auth0/android/provider/PKCE.java | 47 ++++++++- .../auth0/android/provider/WebAuthProvider.kt | 32 ++++++ .../android/provider/OAuthManagerStateTest.kt | 43 ++++++++ .../com/auth0/android/provider/PKCETest.java | 99 +++++++++++++++++++ .../android/provider/WebAuthProviderTest.kt | 38 +++++++ examples/web-auth-configuration.md | 24 +++++ 9 files changed, 314 insertions(+), 6 deletions(-) diff --git a/EXAMPLES.md b/EXAMPLES.md index f584f7e2d..523d3c7ab 100644 --- a/EXAMPLES.md +++ b/EXAMPLES.md @@ -5,7 +5,7 @@ Each topic lives in its own file under [`examples/`](examples). ## Web Auth - [Authenticate with any Auth0 connection](examples/authenticate-with-connection.md) -- [Web Auth configuration](examples/web-auth-configuration.md) — audience, scope, connection scope, parameters, custom authorize URL, return-to URL scheme, custom logout URL +- [Web Auth configuration](examples/web-auth-configuration.md) — audience, scope, connection scope, parameters, custom authorize URL, token exchange retry, return-to URL scheme, custom logout URL - [Customize the Custom Tabs UI](examples/custom-tabs-ui.md) - [Trusted Web Activity](examples/trusted-web-activity.md) - [Ephemeral Browsing](examples/ephemeral-browsing.md) 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 718d3a788..3682645e6 100644 --- a/auth0/src/main/java/com/auth0/android/provider/OAuthManager.kt +++ b/auth0/src/main/java/com/auth0/android/provider/OAuthManager.kt @@ -48,6 +48,8 @@ internal class OAuthManager( private var idTokenVerificationLeeway: Int? = null private var idTokenVerificationIssuer: String? = null + private var tokenExchangeMaxRetries: Int? = null + private var tokenExchangeRetryDelayMillis: Long? = null @VisibleForTesting(otherwise = VisibleForTesting.PACKAGE_PRIVATE) fun setPKCE(pkce: PKCE?) { @@ -58,6 +60,14 @@ internal class OAuthManager( idTokenVerificationLeeway = leeway } + fun setTokenExchangeMaxRetries(maxRetries: Int?) { + tokenExchangeMaxRetries = maxRetries + } + + fun setTokenExchangeRetryDelay(delayMillis: Long?) { + tokenExchangeRetryDelayMillis = delayMillis + } + fun setIdTokenVerificationIssuer(issuer: String?) { idTokenVerificationIssuer = if (TextUtils.isEmpty(issuer)) apiClient.baseURL else issuer } @@ -116,6 +126,10 @@ internal class OAuthManager( } // response_type=code + pkce!!.setNetworkRetry( + tokenExchangeMaxRetries ?: PKCE.DEFAULT_MAX_NETWORK_RETRIES, + tokenExchangeRetryDelayMillis ?: PKCE.DEFAULT_NETWORK_RETRY_DELAY_MS + ) pkce!!.getToken( values[KEY_CODE], object : Callback { @@ -212,6 +226,8 @@ internal class OAuthManager( auth0 = account, idTokenVerificationIssuer = idTokenVerificationIssuer, idTokenVerificationLeeway = idTokenVerificationLeeway, + tokenExchangeMaxRetries = tokenExchangeMaxRetries, + tokenExchangeRetryDelayMillis = tokenExchangeRetryDelayMillis, customAuthorizeUrl = this.customAuthorizeUrl, dPoPEnabled = dPoP != null ) @@ -411,5 +427,7 @@ internal fun OAuthManager.Companion.fromState( setPKCE(state.pkce) setIdTokenVerificationIssuer(state.idTokenVerificationIssuer) setIdTokenVerificationLeeway(state.idTokenVerificationLeeway) + setTokenExchangeMaxRetries(state.tokenExchangeMaxRetries) + setTokenExchangeRetryDelay(state.tokenExchangeRetryDelayMillis) } } \ No newline at end of file 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 06d0c6b0b..1f14bdcef 100644 --- a/auth0/src/main/java/com/auth0/android/provider/OAuthManagerState.kt +++ b/auth0/src/main/java/com/auth0/android/provider/OAuthManagerState.kt @@ -19,7 +19,9 @@ internal data class OAuthManagerState( val idTokenVerificationLeeway: Int?, val idTokenVerificationIssuer: String?, val customAuthorizeUrl: String? = null, - val dPoPEnabled: Boolean = false + val dPoPEnabled: Boolean = false, + val tokenExchangeMaxRetries: Int? = null, + val tokenExchangeRetryDelayMillis: Long? = null ) { private class OAuthManagerJson( @@ -36,7 +38,10 @@ internal data class OAuthManagerState( val idTokenVerificationLeeway: Int?, val idTokenVerificationIssuer: String?, val customAuthorizeUrl: String? = null, - val dPoPEnabled: Boolean + val dPoPEnabled: Boolean, + // nullable so state saved by older versions deserializes to "not set" + val tokenExchangeMaxRetries: Int?, + val tokenExchangeRetryDelayMillis: Long? ) fun serializeToJson( @@ -61,7 +66,9 @@ internal data class OAuthManagerState( idTokenVerificationIssuer = idTokenVerificationIssuer, idTokenVerificationLeeway = idTokenVerificationLeeway, customAuthorizeUrl = this.customAuthorizeUrl, - dPoPEnabled = this.dPoPEnabled + dPoPEnabled = this.dPoPEnabled, + tokenExchangeMaxRetries = tokenExchangeMaxRetries, + tokenExchangeRetryDelayMillis = tokenExchangeRetryDelayMillis ) return gson.toJson(json) } finally { @@ -111,7 +118,9 @@ internal data class OAuthManagerState( idTokenVerificationIssuer = oauthManagerJson.idTokenVerificationIssuer, idTokenVerificationLeeway = oauthManagerJson.idTokenVerificationLeeway, customAuthorizeUrl = oauthManagerJson.customAuthorizeUrl, - dPoPEnabled = oauthManagerJson.dPoPEnabled + dPoPEnabled = oauthManagerJson.dPoPEnabled, + tokenExchangeMaxRetries = oauthManagerJson.tokenExchangeMaxRetries, + tokenExchangeRetryDelayMillis = oauthManagerJson.tokenExchangeRetryDelayMillis ) } finally { parcel.recycle() diff --git a/auth0/src/main/java/com/auth0/android/provider/PKCE.java b/auth0/src/main/java/com/auth0/android/provider/PKCE.java index 515943916..8afc7bb90 100644 --- a/auth0/src/main/java/com/auth0/android/provider/PKCE.java +++ b/auth0/src/main/java/com/auth0/android/provider/PKCE.java @@ -1,14 +1,20 @@ package com.auth0.android.provider; +import android.os.Handler; +import android.os.Looper; + import androidx.annotation.NonNull; import androidx.annotation.VisibleForTesting; +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.request.Request; import com.auth0.android.result.Credentials; +import java.net.ConnectException; +import java.net.UnknownHostException; import java.util.Map; /** @@ -16,12 +22,16 @@ */ class PKCE { static final String TAG = PKCE.class.getSimpleName(); + static final int DEFAULT_MAX_NETWORK_RETRIES = 3; + static final long DEFAULT_NETWORK_RETRY_DELAY_MS = 1000; final AuthenticationAPIClient apiClient; private final String codeVerifier; private final String redirectUri; private final String codeChallenge; private final Map headers; + private int maxNetworkRetries = DEFAULT_MAX_NETWORK_RETRIES; + private long networkRetryDelayMillis = DEFAULT_NETWORK_RETRY_DELAY_MS; /** * Creates a new instance of this class with the given AuthenticationAPIClient. @@ -76,6 +86,11 @@ public String getRedirectUri() { return redirectUri; } + void setNetworkRetry(int maxRetries, long delayMillis) { + this.maxNetworkRetries = maxRetries; + this.networkRetryDelayMillis = delayMillis; + } + /** * Performs a request to the Auth0 API to get the OAuth Token and end the PKCE flow. * The instance of this class must be disposed after this method is called. @@ -88,7 +103,37 @@ public void getToken(String authorizationCode, @NonNull final Callback entry : headers.entrySet()) { tokenRequest.addHeader(entry.getKey(), entry.getValue()); } - tokenRequest.start(callback); + tokenRequest.start(new Callback() { + private int retries = 0; + + @Override + public void onSuccess(@NonNull Credentials result) { + callback.onSuccess(result); + } + + @Override + public void onFailure(@NonNull AuthenticationException error) { + // Android 15+ blocks network for cached apps and lifts it asynchronously when the + // redirect brings the app back, so the exchange can race it. Only failures before + // the request was sent are retried, as the authorization code is single-use. + if (retries < maxNetworkRetries && isUnsentRequestError(error)) { + retries++; + Callback retry = this; + new Handler(Looper.getMainLooper()).postDelayed(() -> tokenRequest.start(retry), networkRetryDelayMillis); + return; + } + callback.onFailure(error); + } + }); + } + + private static boolean isUnsentRequestError(AuthenticationException error) { + Throwable cause = error.getCause(); + if (!(cause instanceof NetworkErrorException)) { + return false; + } + Throwable networkCause = cause.getCause(); + return networkCause instanceof UnknownHostException || networkCause instanceof ConnectException; } /** 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 7c59c8257..408ceb1ee 100644 --- a/auth0/src/main/java/com/auth0/android/provider/WebAuthProvider.kt +++ b/auth0/src/main/java/com/auth0/android/provider/WebAuthProvider.kt @@ -526,6 +526,8 @@ public object WebAuthProvider { private var dPoP: DPoP? = null private var ctOptions: CustomTabsOptions = CustomTabsOptions.newBuilder().build() private var leeway: Int? = null + private var tokenExchangeMaxRetries: Int? = null + private var tokenExchangeRetryDelayMillis: Long? = null private var launchAsTwa: Boolean = false private var ephemeralBrowsing: Boolean = false private var authTab: Boolean = false @@ -580,6 +582,34 @@ public object WebAuthProvider { return this } + /** + * Set how many times the code exchange that follows the browser redirect is retried when + * it fails before reaching Auth0, for example because the OS hasn't restored the app's + * network access yet after it was in the background. Requests that may have reached Auth0 + * are never retried. Defaults to 3. Pass 0 to disable retries. + * + * @param maxRetries the maximum number of retries, must not be negative. + * @return the current builder instance + */ + public fun withTokenExchangeMaxRetries(maxRetries: Int): Builder { + require(maxRetries >= 0) { "maxRetries must not be negative" } + this.tokenExchangeMaxRetries = maxRetries + return this + } + + /** + * Set the delay between code exchange retries. See [withTokenExchangeMaxRetries]. + * Defaults to 1000 milliseconds. + * + * @param delayMillis the delay between retries, in milliseconds, must not be negative. + * @return the current builder instance + */ + public fun withTokenExchangeRetryDelay(delayMillis: Long): Builder { + require(delayMillis >= 0) { "delayMillis must not be negative" } + this.tokenExchangeRetryDelayMillis = delayMillis + return this + } + /** * Set the expected issuer to be used for ID Token verification. * Defaults to the value returned by [Auth0.getDomainUrl]. @@ -898,6 +928,8 @@ public object WebAuthProvider { manager.setPKCE(pkce) manager.setIdTokenVerificationLeeway(leeway) manager.setIdTokenVerificationIssuer(issuer) + manager.setTokenExchangeMaxRetries(tokenExchangeMaxRetries) + manager.setTokenExchangeRetryDelay(tokenExchangeRetryDelayMillis) managerInstance = manager if (redirectUri == null) { redirectUri = CallbackHelper.getCallbackUri( 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 2a32f745f..da111637d 100644 --- a/auth0/src/test/java/com/auth0/android/provider/OAuthManagerStateTest.kt +++ b/auth0/src/test/java/com/auth0/android/provider/OAuthManagerStateTest.kt @@ -45,6 +45,49 @@ internal class OAuthManagerStateTest { Assert.assertEquals("issuer", deserializedState.idTokenVerificationIssuer) } + @Test + fun `serialize should persist token exchange retry settings`() { + val state = OAuthManagerState( + auth0 = Auth0.getInstance("clientId", "domain"), + parameters = emptyMap(), + headers = emptyMap(), + ctOptions = CustomTabsOptions.newBuilder().build(), + pkce = PKCE(mock(), "redirectUri", emptyMap()), + idTokenVerificationLeeway = null, + idTokenVerificationIssuer = null, + tokenExchangeMaxRetries = 5, + tokenExchangeRetryDelayMillis = 200 + ) + + val deserializedState = OAuthManagerState.deserializeState(state.serializeToJson()) + + Assert.assertEquals(5, deserializedState.tokenExchangeMaxRetries) + Assert.assertEquals(200L, deserializedState.tokenExchangeRetryDelayMillis) + } + + @Test + fun `deserialize should leave token exchange retry settings unset for state saved without them`() { + val state = OAuthManagerState( + auth0 = Auth0.getInstance("clientId", "domain"), + parameters = emptyMap(), + headers = emptyMap(), + ctOptions = CustomTabsOptions.newBuilder().build(), + pkce = PKCE(mock(), "redirectUri", emptyMap()), + idTokenVerificationLeeway = null, + idTokenVerificationIssuer = null, + tokenExchangeMaxRetries = 5, + tokenExchangeRetryDelayMillis = 200 + ) + val json = org.json.JSONObject(state.serializeToJson()) + json.remove("tokenExchangeMaxRetries") + json.remove("tokenExchangeRetryDelayMillis") + + val deserializedState = OAuthManagerState.deserializeState(json.toString()) + + Assert.assertNull(deserializedState.tokenExchangeMaxRetries) + Assert.assertNull(deserializedState.tokenExchangeRetryDelayMillis) + } + @Test fun `serialize should persist dPoPEnabled flag as true`() { val auth0 = Auth0.getInstance("clientId", "domain") diff --git a/auth0/src/test/java/com/auth0/android/provider/PKCETest.java b/auth0/src/test/java/com/auth0/android/provider/PKCETest.java index d6d1ad58c..dc4e35fa6 100644 --- a/auth0/src/test/java/com/auth0/android/provider/PKCETest.java +++ b/auth0/src/test/java/com/auth0/android/provider/PKCETest.java @@ -1,5 +1,6 @@ package com.auth0.android.provider; +import com.auth0.android.NetworkErrorException; import com.auth0.android.authentication.AuthenticationAPIClient; import com.auth0.android.authentication.AuthenticationException; import com.auth0.android.callback.Callback; @@ -16,10 +17,14 @@ import org.mockito.Mockito; import org.mockito.MockitoAnnotations; import org.robolectric.RobolectricTestRunner; +import org.robolectric.shadows.ShadowLooper; +import java.net.SocketTimeoutException; +import java.net.UnknownHostException; import java.util.Collections; import java.util.HashMap; import java.util.Map; +import java.util.concurrent.TimeUnit; import static org.hamcrest.CoreMatchers.containsString; import static org.hamcrest.CoreMatchers.equalTo; @@ -32,6 +37,8 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -120,6 +127,98 @@ public void shouldFailToGetToken() { verify(callback).onFailure(any(AuthenticationException.class)); } + @Test + public void shouldRetryTokenExchangeWhenHostIsUnreachable() { + Request tokenRequest = mock(Request.class); + when(apiClient.token(AUTHORIZATION_CODE, CODE_VERIFIER, REDIRECT_URI)).thenReturn(tokenRequest); + pkce.getToken(AUTHORIZATION_CODE, callback); + ArgumentCaptor callbackCaptor = ArgumentCaptor.forClass(Callback.class); + verify(tokenRequest).start(callbackCaptor.capture()); + + callbackCaptor.getValue().onFailure(networkError(new UnknownHostException())); + ShadowLooper.idleMainLooper(1, TimeUnit.SECONDS); + verify(tokenRequest, times(2)).start(callbackCaptor.capture()); + Credentials credentials = mock(Credentials.class); + callbackCaptor.getValue().onSuccess(credentials); + + verify(callback).onSuccess(credentials); + verify(callback, never()).onFailure(any(AuthenticationException.class)); + } + + @Test + public void shouldFailTokenExchangeWhenHostStaysUnreachable() { + Request tokenRequest = mock(Request.class); + when(apiClient.token(AUTHORIZATION_CODE, CODE_VERIFIER, REDIRECT_URI)).thenReturn(tokenRequest); + pkce.getToken(AUTHORIZATION_CODE, callback); + ArgumentCaptor callbackCaptor = ArgumentCaptor.forClass(Callback.class); + AuthenticationException error = networkError(new UnknownHostException()); + for (int attempt = 1; attempt <= 4; attempt++) { + verify(tokenRequest, times(attempt)).start(callbackCaptor.capture()); + callbackCaptor.getValue().onFailure(error); + ShadowLooper.idleMainLooper(1, TimeUnit.SECONDS); + } + + verify(tokenRequest, times(4)).start(any(Callback.class)); + verify(callback).onFailure(error); + } + + @Test + public void shouldNotRetryTokenExchangeWhenRequestMayHaveBeenSent() { + Request tokenRequest = mock(Request.class); + when(apiClient.token(AUTHORIZATION_CODE, CODE_VERIFIER, REDIRECT_URI)).thenReturn(tokenRequest); + pkce.getToken(AUTHORIZATION_CODE, callback); + ArgumentCaptor callbackCaptor = ArgumentCaptor.forClass(Callback.class); + verify(tokenRequest).start(callbackCaptor.capture()); + + AuthenticationException error = networkError(new SocketTimeoutException()); + callbackCaptor.getValue().onFailure(error); + ShadowLooper.idleMainLooper(1, TimeUnit.SECONDS); + + verify(tokenRequest, times(1)).start(any(Callback.class)); + verify(callback).onFailure(error); + } + + @Test + public void shouldUseConfiguredTokenExchangeRetries() { + Request tokenRequest = mock(Request.class); + when(apiClient.token(AUTHORIZATION_CODE, CODE_VERIFIER, REDIRECT_URI)).thenReturn(tokenRequest); + pkce.setNetworkRetry(1, 5000); + pkce.getToken(AUTHORIZATION_CODE, callback); + ArgumentCaptor callbackCaptor = ArgumentCaptor.forClass(Callback.class); + AuthenticationException error = networkError(new UnknownHostException()); + + verify(tokenRequest).start(callbackCaptor.capture()); + callbackCaptor.getValue().onFailure(error); + ShadowLooper.idleMainLooper(4999, TimeUnit.MILLISECONDS); + verify(tokenRequest, times(1)).start(any(Callback.class)); + ShadowLooper.idleMainLooper(1, TimeUnit.MILLISECONDS); + verify(tokenRequest, times(2)).start(callbackCaptor.capture()); + callbackCaptor.getValue().onFailure(error); + + verify(callback).onFailure(error); + } + + @Test + public void shouldNotRetryTokenExchangeWhenRetriesAreDisabled() { + Request tokenRequest = mock(Request.class); + when(apiClient.token(AUTHORIZATION_CODE, CODE_VERIFIER, REDIRECT_URI)).thenReturn(tokenRequest); + pkce.setNetworkRetry(0, 1000); + pkce.getToken(AUTHORIZATION_CODE, callback); + ArgumentCaptor callbackCaptor = ArgumentCaptor.forClass(Callback.class); + verify(tokenRequest).start(callbackCaptor.capture()); + + AuthenticationException error = networkError(new UnknownHostException()); + callbackCaptor.getValue().onFailure(error); + ShadowLooper.idleMainLooper(1, TimeUnit.SECONDS); + + verify(tokenRequest, times(1)).start(any(Callback.class)); + verify(callback).onFailure(error); + } + + private static AuthenticationException networkError(Exception cause) { + return new AuthenticationException("Failed to execute the network request", new NetworkErrorException(cause)); + } + @Test public void shouldNotHavePKCEAvailableIfSHA256IsNotAvailable() { AlgorithmHelper algorithmHelper = Mockito.mock(AlgorithmHelper.class); 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 196830c44..78dfe3e2b 100644 --- a/auth0/src/test/java/com/auth0/android/provider/WebAuthProviderTest.kt +++ b/auth0/src/test/java/com/auth0/android/provider/WebAuthProviderTest.kt @@ -1066,6 +1066,44 @@ public class WebAuthProviderTest { ) } + @Test + public fun shouldUseDefaultTokenExchangeRetryOnResume() { + val pkce = Mockito.mock(PKCE::class.java) + `when`(pkce.codeChallenge).thenReturn("challenge") + login(account) + .withPKCE(pkce) + .withState("1234567890") + .start(activity, callback) + Assert.assertTrue(resume(createAuthIntent(createHash(null, null, null, null, null, "1234567890", null, null, "1234")))) + verify(pkce).setNetworkRetry(3, 1000L) + verify(pkce).getToken(eq("1234"), any()) + } + + @Test + public fun shouldUseCustomTokenExchangeRetryOnResume() { + val pkce = Mockito.mock(PKCE::class.java) + `when`(pkce.codeChallenge).thenReturn("challenge") + login(account) + .withPKCE(pkce) + .withState("1234567890") + .withTokenExchangeMaxRetries(5) + .withTokenExchangeRetryDelay(200) + .start(activity, callback) + Assert.assertTrue(resume(createAuthIntent(createHash(null, null, null, null, null, "1234567890", null, null, "1234")))) + verify(pkce).setNetworkRetry(5, 200L) + verify(pkce).getToken(eq("1234"), any()) + } + + @Test(expected = IllegalArgumentException::class) + public fun shouldRejectNegativeTokenExchangeMaxRetries() { + login(account).withTokenExchangeMaxRetries(-1) + } + + @Test(expected = IllegalArgumentException::class) + public fun shouldRejectNegativeTokenExchangeRetryDelay() { + login(account).withTokenExchangeRetryDelay(-1) + } + @Test public fun shouldStartLoginWithValidRequestCode() { val credentials = Mockito.mock( diff --git a/examples/web-auth-configuration.md b/examples/web-auth-configuration.md index 1e5c2b7da..59a4911f0 100644 --- a/examples/web-auth-configuration.md +++ b/examples/web-auth-configuration.md @@ -62,6 +62,30 @@ WebAuthProvider The URL provided to `.withAuthorizeUrl()` must be a complete and valid HTTPS URL for an OAuth 2.0 authorize endpoint. The SDK will append standard OAuth parameters to this custom base URL. +## Configure the token exchange retry + +After the browser redirects back, the SDK exchanges the authorization code for tokens. On some devices, such as Android 15+ or devices with aggressive battery optimization, the OS blocks network access for apps in the background and restores it a moment after the app returns to the foreground. If the exchange fails before reaching Auth0 for this reason, the SDK retries it 3 times, 1000 milliseconds apart. Requests that may have reached Auth0 are never retried, because the authorization code can only be used once. + +You can change the number of retries and the delay between them, or pass `0` retries to disable them: + +```kotlin +WebAuthProvider.login(account) + .withTokenExchangeMaxRetries(5) + .withTokenExchangeRetryDelay(500) + .start(this, callback) +``` + +
+ Using Java + +```java +WebAuthProvider.login(account) + .withTokenExchangeMaxRetries(5) + .withTokenExchangeRetryDelay(500) + .start(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 807c533feb0ba282ce90f1956af67593574a3033 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 6 Nov 2026 18:08:33 +0530 Subject: [PATCH 17/23] chore(deps): bump the codeql group with 4 updates (#1126) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/codeql.yml | 6 +++--- .github/workflows/scorecard.yml | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index a9324ec5b..648133780 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -45,15 +45,15 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Initialize CodeQL - uses: github/codeql-action/init@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4.39.1 + uses: github/codeql-action/init@1fc58d04a4d4374f921bdac573c3811c240f9c26 # v4.39.2 with: languages: ${{ matrix.language }} queries: +security-and-quality - name: Autobuild - uses: github/codeql-action/autobuild@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4.39.1 + uses: github/codeql-action/autobuild@1fc58d04a4d4374f921bdac573c3811c240f9c26 # v4.39.2 - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4.39.1 + uses: github/codeql-action/analyze@1fc58d04a4d4374f921bdac573c3811c240f9c26 # v4.39.2 with: category: "/language:${{ matrix.language }}" diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml index 6e9d1d67d..a26ecd04a 100644 --- a/.github/workflows/scorecard.yml +++ b/.github/workflows/scorecard.yml @@ -74,7 +74,7 @@ jobs: # token can write. (github.event.pull_request is null on push/schedule, so !…fork is true.) - name: Upload to code-scanning if: ${{ !github.event.pull_request.head.repo.fork }} - uses: github/codeql-action/upload-sarif@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4.39.1 + uses: github/codeql-action/upload-sarif@1fc58d04a4d4374f921bdac573c3811c240f9c26 # v4.39.2 with: sarif_file: results.sarif category: scorecard From 58dc51b57454723b563ecdc5f25be1794c553a6c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 10 Nov 2026 10:30:13 +0530 Subject: [PATCH 18/23] chore(deps): bump actions/setup-java from 6.1.0 to 6.2.0 in the setup-java group (#1129) 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 648133780..7fb4278e7 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@b843ebf9b913146b38da2eaa7aeddf76074ae1ad # v6.1.0 + uses: actions/setup-java@77dd5b2ce225ea11469928ac317850245a74ae78 # v6.2.0 with: distribution: 'temurin' java-version: '17' From feef30bbc4b164cfbeb2892833fa771575adeb5c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 10 Nov 2026 10:51:31 +0530 Subject: [PATCH 19/23] chore(deps): bump the codeql group with 4 updates (#1128) 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 | 6 +++--- .github/workflows/scorecard.yml | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 7fb4278e7..d21b96de4 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -45,15 +45,15 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Initialize CodeQL - uses: github/codeql-action/init@1fc58d04a4d4374f921bdac573c3811c240f9c26 # v4.39.2 + uses: github/codeql-action/init@33d7b85da1a04171e211befe2c739303b9a4c066 # v4.39.3 with: languages: ${{ matrix.language }} queries: +security-and-quality - name: Autobuild - uses: github/codeql-action/autobuild@1fc58d04a4d4374f921bdac573c3811c240f9c26 # v4.39.2 + uses: github/codeql-action/autobuild@33d7b85da1a04171e211befe2c739303b9a4c066 # v4.39.3 - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@1fc58d04a4d4374f921bdac573c3811c240f9c26 # v4.39.2 + uses: github/codeql-action/analyze@33d7b85da1a04171e211befe2c739303b9a4c066 # v4.39.3 with: category: "/language:${{ matrix.language }}" diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml index a26ecd04a..7b9a99e46 100644 --- a/.github/workflows/scorecard.yml +++ b/.github/workflows/scorecard.yml @@ -74,7 +74,7 @@ jobs: # token can write. (github.event.pull_request is null on push/schedule, so !…fork is true.) - name: Upload to code-scanning if: ${{ !github.event.pull_request.head.repo.fork }} - uses: github/codeql-action/upload-sarif@1fc58d04a4d4374f921bdac573c3811c240f9c26 # v4.39.2 + uses: github/codeql-action/upload-sarif@33d7b85da1a04171e211befe2c739303b9a4c066 # v4.39.3 with: sarif_file: results.sarif category: scorecard From f0e4fa8f88aec63fe46c6375318964d2dc4d9b00 Mon Sep 17 00:00:00 2001 From: Subhankar Maiti Date: Thu, 12 Nov 2026 11:39:41 +0530 Subject: [PATCH 20/23] address review comments and add max retry --- .../auth0/android/provider/OAuthManager.kt | 16 +--- .../android/provider/OAuthManagerState.kt | 16 ++-- .../java/com/auth0/android/provider/PKCE.java | 93 ++++++++++++++----- .../auth0/android/provider/WebAuthProvider.kt | 23 +++-- .../android/provider/OAuthManagerStateTest.kt | 24 ++--- .../com/auth0/android/provider/PKCETest.java | 87 +++++++++++------ .../android/provider/WebAuthProviderTest.kt | 41 ++++---- examples/web-auth-configuration.md | 8 +- 8 files changed, 186 insertions(+), 122 deletions(-) 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 3682645e6..aae029a86 100644 --- a/auth0/src/main/java/com/auth0/android/provider/OAuthManager.kt +++ b/auth0/src/main/java/com/auth0/android/provider/OAuthManager.kt @@ -49,7 +49,7 @@ internal class OAuthManager( private var idTokenVerificationLeeway: Int? = null private var idTokenVerificationIssuer: String? = null private var tokenExchangeMaxRetries: Int? = null - private var tokenExchangeRetryDelayMillis: Long? = null + private var tokenExchangeRetryDelay: Int? = null @VisibleForTesting(otherwise = VisibleForTesting.PACKAGE_PRIVATE) fun setPKCE(pkce: PKCE?) { @@ -64,8 +64,8 @@ internal class OAuthManager( tokenExchangeMaxRetries = maxRetries } - fun setTokenExchangeRetryDelay(delayMillis: Long?) { - tokenExchangeRetryDelayMillis = delayMillis + fun setTokenExchangeRetryDelay(seconds: Int?) { + tokenExchangeRetryDelay = seconds } fun setIdTokenVerificationIssuer(issuer: String?) { @@ -126,10 +126,6 @@ internal class OAuthManager( } // response_type=code - pkce!!.setNetworkRetry( - tokenExchangeMaxRetries ?: PKCE.DEFAULT_MAX_NETWORK_RETRIES, - tokenExchangeRetryDelayMillis ?: PKCE.DEFAULT_NETWORK_RETRY_DELAY_MS - ) pkce!!.getToken( values[KEY_CODE], object : Callback { @@ -226,8 +222,6 @@ internal class OAuthManager( auth0 = account, idTokenVerificationIssuer = idTokenVerificationIssuer, idTokenVerificationLeeway = idTokenVerificationLeeway, - tokenExchangeMaxRetries = tokenExchangeMaxRetries, - tokenExchangeRetryDelayMillis = tokenExchangeRetryDelayMillis, customAuthorizeUrl = this.customAuthorizeUrl, dPoPEnabled = dPoP != null ) @@ -320,7 +314,7 @@ internal class OAuthManager( dPoP?.let { apiClient.useDPoP(context) } - pkce = PKCE(apiClient, redirectUri, headers) + pkce = PKCE(apiClient, redirectUri, headers, tokenExchangeMaxRetries, tokenExchangeRetryDelay) } } @@ -427,7 +421,5 @@ internal fun OAuthManager.Companion.fromState( setPKCE(state.pkce) setIdTokenVerificationIssuer(state.idTokenVerificationIssuer) setIdTokenVerificationLeeway(state.idTokenVerificationLeeway) - setTokenExchangeMaxRetries(state.tokenExchangeMaxRetries) - setTokenExchangeRetryDelay(state.tokenExchangeRetryDelayMillis) } } \ No newline at end of file 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 1f14bdcef..aa1f12653 100644 --- a/auth0/src/main/java/com/auth0/android/provider/OAuthManagerState.kt +++ b/auth0/src/main/java/com/auth0/android/provider/OAuthManagerState.kt @@ -19,9 +19,7 @@ internal data class OAuthManagerState( val idTokenVerificationLeeway: Int?, val idTokenVerificationIssuer: String?, val customAuthorizeUrl: String? = null, - val dPoPEnabled: Boolean = false, - val tokenExchangeMaxRetries: Int? = null, - val tokenExchangeRetryDelayMillis: Long? = null + val dPoPEnabled: Boolean = false ) { private class OAuthManagerJson( @@ -41,7 +39,7 @@ internal data class OAuthManagerState( val dPoPEnabled: Boolean, // nullable so state saved by older versions deserializes to "not set" val tokenExchangeMaxRetries: Int?, - val tokenExchangeRetryDelayMillis: Long? + val tokenExchangeRetryDelay: Int? ) fun serializeToJson( @@ -67,8 +65,8 @@ internal data class OAuthManagerState( idTokenVerificationLeeway = idTokenVerificationLeeway, customAuthorizeUrl = this.customAuthorizeUrl, dPoPEnabled = this.dPoPEnabled, - tokenExchangeMaxRetries = tokenExchangeMaxRetries, - tokenExchangeRetryDelayMillis = tokenExchangeRetryDelayMillis + tokenExchangeMaxRetries = pkce?.maxNetworkRetries, + tokenExchangeRetryDelay = pkce?.networkRetryDelaySeconds ) return gson.toJson(json) } finally { @@ -114,13 +112,13 @@ internal data class OAuthManagerState( oauthManagerJson.redirectUri, oauthManagerJson.codeChallenge, oauthManagerJson.headers, + oauthManagerJson.tokenExchangeMaxRetries, + oauthManagerJson.tokenExchangeRetryDelay, ), idTokenVerificationIssuer = oauthManagerJson.idTokenVerificationIssuer, idTokenVerificationLeeway = oauthManagerJson.idTokenVerificationLeeway, customAuthorizeUrl = oauthManagerJson.customAuthorizeUrl, - dPoPEnabled = oauthManagerJson.dPoPEnabled, - tokenExchangeMaxRetries = oauthManagerJson.tokenExchangeMaxRetries, - tokenExchangeRetryDelayMillis = oauthManagerJson.tokenExchangeRetryDelayMillis + dPoPEnabled = oauthManagerJson.dPoPEnabled ) } finally { parcel.recycle() diff --git a/auth0/src/main/java/com/auth0/android/provider/PKCE.java b/auth0/src/main/java/com/auth0/android/provider/PKCE.java index 8afc7bb90..42641f5db 100644 --- a/auth0/src/main/java/com/auth0/android/provider/PKCE.java +++ b/auth0/src/main/java/com/auth0/android/provider/PKCE.java @@ -4,6 +4,7 @@ import android.os.Looper; import androidx.annotation.NonNull; +import androidx.annotation.Nullable; import androidx.annotation.VisibleForTesting; import com.auth0.android.NetworkErrorException; @@ -22,16 +23,18 @@ */ class PKCE { static final String TAG = PKCE.class.getSimpleName(); - static final int DEFAULT_MAX_NETWORK_RETRIES = 3; - static final long DEFAULT_NETWORK_RETRY_DELAY_MS = 1000; + private static final int DEFAULT_MAX_NETWORK_RETRIES = 2; + // Android caches failed DNS lookups for 2 seconds, so a shorter delay retries against the cached failure. + private static final int DEFAULT_NETWORK_RETRY_DELAY_SECONDS = 2; final AuthenticationAPIClient apiClient; private final String codeVerifier; private final String redirectUri; private final String codeChallenge; private final Map headers; - private int maxNetworkRetries = DEFAULT_MAX_NETWORK_RETRIES; - private long networkRetryDelayMillis = DEFAULT_NETWORK_RETRY_DELAY_MS; + private final int maxNetworkRetries; + private final int networkRetryDelaySeconds; + private final Handler retryHandler = new Handler(Looper.getMainLooper()); /** * Creates a new instance of this class with the given AuthenticationAPIClient. @@ -44,29 +47,53 @@ class PKCE { * @see #isAvailable() */ public PKCE(@NonNull AuthenticationAPIClient apiClient, String redirectUri, @NonNull Map headers) { - this(apiClient, new AlgorithmHelper(), redirectUri, headers); + this(apiClient, redirectUri, headers, null, null); + } + + /** + * Same as {@link #PKCE(AuthenticationAPIClient, String, Map)}, with the code exchange retry settings. + * + * @param maxNetworkRetries retries when the code exchange fails before it was sent, or null for the default. + * @param networkRetryDelaySeconds delay between those retries, or null for the default. + */ + PKCE(@NonNull AuthenticationAPIClient apiClient, String redirectUri, @NonNull Map headers, + @Nullable Integer maxNetworkRetries, @Nullable Integer networkRetryDelaySeconds) { + this(apiClient, new AlgorithmHelper(), redirectUri, headers, maxNetworkRetries, networkRetryDelaySeconds); } @VisibleForTesting PKCE(@NonNull AuthenticationAPIClient apiClient, @NonNull AlgorithmHelper algorithmHelper, @NonNull String redirectUri, @NonNull Map headers) { + this(apiClient, algorithmHelper, redirectUri, headers, null, null); + } + + @VisibleForTesting + PKCE(@NonNull AuthenticationAPIClient apiClient, @NonNull AlgorithmHelper algorithmHelper, + @NonNull String redirectUri, @NonNull Map headers, + @Nullable Integer maxNetworkRetries, @Nullable Integer networkRetryDelaySeconds) { this.apiClient = apiClient; this.redirectUri = redirectUri; this.codeVerifier = algorithmHelper.generateCodeVerifier(); this.codeChallenge = algorithmHelper.generateCodeChallenge(codeVerifier); this.headers = headers; + this.maxNetworkRetries = maxNetworkRetries != null ? maxNetworkRetries : DEFAULT_MAX_NETWORK_RETRIES; + this.networkRetryDelaySeconds = networkRetryDelaySeconds != null ? networkRetryDelaySeconds : DEFAULT_NETWORK_RETRY_DELAY_SECONDS; } PKCE(@NonNull AuthenticationAPIClient apiClient, @NonNull String codeVerifier, @NonNull String redirectUri, @NonNull String codeChallenge, - @NonNull Map headers) { + @NonNull Map headers, + @Nullable Integer maxNetworkRetries, + @Nullable Integer networkRetryDelaySeconds) { this.apiClient = apiClient; this.codeVerifier = codeVerifier; this.redirectUri = redirectUri; this.codeChallenge = codeChallenge; this.headers = headers; + this.maxNetworkRetries = maxNetworkRetries != null ? maxNetworkRetries : DEFAULT_MAX_NETWORK_RETRIES; + this.networkRetryDelaySeconds = networkRetryDelaySeconds != null ? networkRetryDelaySeconds : DEFAULT_NETWORK_RETRY_DELAY_SECONDS; } /** @@ -86,9 +113,12 @@ public String getRedirectUri() { return redirectUri; } - void setNetworkRetry(int maxRetries, long delayMillis) { - this.maxNetworkRetries = maxRetries; - this.networkRetryDelayMillis = delayMillis; + int getMaxNetworkRetries() { + return maxNetworkRetries; + } + + int getNetworkRetryDelaySeconds() { + return networkRetryDelaySeconds; } /** @@ -103,9 +133,13 @@ public void getToken(String authorizationCode, @NonNull final Callback entry : headers.entrySet()) { tokenRequest.addHeader(entry.getKey(), entry.getValue()); } - tokenRequest.start(new Callback() { - private int retries = 0; + startTokenRequest(tokenRequest, callback, 0); + } + private void startTokenRequest(@NonNull Request tokenRequest, + @NonNull Callback callback, + int attempt) { + tokenRequest.start(new Callback() { @Override public void onSuccess(@NonNull Credentials result) { callback.onSuccess(result); @@ -113,27 +147,38 @@ public void onSuccess(@NonNull Credentials result) { @Override public void onFailure(@NonNull AuthenticationException error) { - // Android 15+ blocks network for cached apps and lifts it asynchronously when the - // redirect brings the app back, so the exchange can race it. Only failures before - // the request was sent are retried, as the authorization code is single-use. - if (retries < maxNetworkRetries && isUnsentRequestError(error)) { - retries++; - Callback retry = this; - new Handler(Looper.getMainLooper()).postDelayed(() -> tokenRequest.start(retry), networkRetryDelayMillis); - return; + if (shouldRetry(error, attempt)) { + retryHandler.postDelayed(() -> startTokenRequest(tokenRequest, callback, attempt + 1), + networkRetryDelaySeconds * 1000L); + } else { + callback.onFailure(error); } - callback.onFailure(error); } }); } - private static boolean isUnsentRequestError(AuthenticationException error) { + // Android 15+ blocks network for cached apps and lifts it asynchronously when the redirect + // brings the app back, so the exchange can race it. Only failures before the request was sent + // are retried, as the authorization code is single-use. + private boolean shouldRetry(@NonNull AuthenticationException error, int attempt) { Throwable cause = error.getCause(); - if (!(cause instanceof NetworkErrorException)) { + return attempt < maxNetworkRetries + && cause instanceof NetworkErrorException + && isUnsentRequestError(cause.getCause()); + } + + // OkHttp attaches the failures it already recovered from as suppressed exceptions, and any of + // those that isn't a DNS or connect failure means the request may have been sent. + private static boolean isUnsentRequestError(@Nullable Throwable error) { + if (!(error instanceof UnknownHostException || error instanceof ConnectException)) { return false; } - Throwable networkCause = cause.getCause(); - return networkCause instanceof UnknownHostException || networkCause instanceof ConnectException; + for (Throwable suppressed : error.getSuppressed()) { + if (!isUnsentRequestError(suppressed)) { + return false; + } + } + return true; } /** 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 408ceb1ee..bdea1c459 100644 --- a/auth0/src/main/java/com/auth0/android/provider/WebAuthProvider.kt +++ b/auth0/src/main/java/com/auth0/android/provider/WebAuthProvider.kt @@ -37,6 +37,7 @@ public object WebAuthProvider { 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 const val MAX_TOKEN_EXCHANGE_RETRIES = 10 private val callbacks = CopyOnWriteArraySet>() private val parCallbacks = @@ -527,7 +528,7 @@ public object WebAuthProvider { private var ctOptions: CustomTabsOptions = CustomTabsOptions.newBuilder().build() private var leeway: Int? = null private var tokenExchangeMaxRetries: Int? = null - private var tokenExchangeRetryDelayMillis: Long? = null + private var tokenExchangeRetryDelay: Int? = null private var launchAsTwa: Boolean = false private var ephemeralBrowsing: Boolean = false private var authTab: Boolean = false @@ -586,27 +587,29 @@ public object WebAuthProvider { * Set how many times the code exchange that follows the browser redirect is retried when * it fails before reaching Auth0, for example because the OS hasn't restored the app's * network access yet after it was in the background. Requests that may have reached Auth0 - * are never retried. Defaults to 3. Pass 0 to disable retries. + * are never retried. Defaults to 2. Pass 0 to disable retries. * - * @param maxRetries the maximum number of retries, must not be negative. + * @param maxRetries the maximum number of retries, from 0 to 10. * @return the current builder instance */ public fun withTokenExchangeMaxRetries(maxRetries: Int): Builder { - require(maxRetries >= 0) { "maxRetries must not be negative" } + require(maxRetries in 0..MAX_TOKEN_EXCHANGE_RETRIES) { + "maxRetries must be between 0 and $MAX_TOKEN_EXCHANGE_RETRIES" + } this.tokenExchangeMaxRetries = maxRetries return this } /** * Set the delay between code exchange retries. See [withTokenExchangeMaxRetries]. - * Defaults to 1000 milliseconds. + * Defaults to 2 seconds. * - * @param delayMillis the delay between retries, in milliseconds, must not be negative. + * @param delay the delay between retries, in seconds, must not be negative. * @return the current builder instance */ - public fun withTokenExchangeRetryDelay(delayMillis: Long): Builder { - require(delayMillis >= 0) { "delayMillis must not be negative" } - this.tokenExchangeRetryDelayMillis = delayMillis + public fun withTokenExchangeRetryDelay(delay: Int): Builder { + require(delay >= 0) { "delay must not be negative" } + this.tokenExchangeRetryDelay = delay return this } @@ -929,7 +932,7 @@ public object WebAuthProvider { manager.setIdTokenVerificationLeeway(leeway) manager.setIdTokenVerificationIssuer(issuer) manager.setTokenExchangeMaxRetries(tokenExchangeMaxRetries) - manager.setTokenExchangeRetryDelay(tokenExchangeRetryDelayMillis) + manager.setTokenExchangeRetryDelay(tokenExchangeRetryDelay) managerInstance = manager if (redirectUri == null) { redirectUri = CallbackHelper.getCallbackUri( 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 da111637d..c562a99e6 100644 --- a/auth0/src/test/java/com/auth0/android/provider/OAuthManagerStateTest.kt +++ b/auth0/src/test/java/com/auth0/android/provider/OAuthManagerStateTest.kt @@ -52,40 +52,36 @@ internal class OAuthManagerStateTest { parameters = emptyMap(), headers = emptyMap(), ctOptions = CustomTabsOptions.newBuilder().build(), - pkce = PKCE(mock(), "redirectUri", emptyMap()), + pkce = PKCE(mock(), "redirectUri", emptyMap(), 5, 3), idTokenVerificationLeeway = null, - idTokenVerificationIssuer = null, - tokenExchangeMaxRetries = 5, - tokenExchangeRetryDelayMillis = 200 + idTokenVerificationIssuer = null ) val deserializedState = OAuthManagerState.deserializeState(state.serializeToJson()) - Assert.assertEquals(5, deserializedState.tokenExchangeMaxRetries) - Assert.assertEquals(200L, deserializedState.tokenExchangeRetryDelayMillis) + Assert.assertEquals(5, deserializedState.pkce?.maxNetworkRetries) + Assert.assertEquals(3, deserializedState.pkce?.networkRetryDelaySeconds) } @Test - fun `deserialize should leave token exchange retry settings unset for state saved without them`() { + fun `deserialize should use default token exchange retry settings for state saved without them`() { val state = OAuthManagerState( auth0 = Auth0.getInstance("clientId", "domain"), parameters = emptyMap(), headers = emptyMap(), ctOptions = CustomTabsOptions.newBuilder().build(), - pkce = PKCE(mock(), "redirectUri", emptyMap()), + pkce = PKCE(mock(), "redirectUri", emptyMap(), 5, 3), idTokenVerificationLeeway = null, - idTokenVerificationIssuer = null, - tokenExchangeMaxRetries = 5, - tokenExchangeRetryDelayMillis = 200 + idTokenVerificationIssuer = null ) val json = org.json.JSONObject(state.serializeToJson()) json.remove("tokenExchangeMaxRetries") - json.remove("tokenExchangeRetryDelayMillis") + json.remove("tokenExchangeRetryDelay") val deserializedState = OAuthManagerState.deserializeState(json.toString()) - Assert.assertNull(deserializedState.tokenExchangeMaxRetries) - Assert.assertNull(deserializedState.tokenExchangeRetryDelayMillis) + Assert.assertEquals(2, deserializedState.pkce?.maxNetworkRetries) + Assert.assertEquals(2, deserializedState.pkce?.networkRetryDelaySeconds) } @Test diff --git a/auth0/src/test/java/com/auth0/android/provider/PKCETest.java b/auth0/src/test/java/com/auth0/android/provider/PKCETest.java index dc4e35fa6..5dbdd1e8e 100644 --- a/auth0/src/test/java/com/auth0/android/provider/PKCETest.java +++ b/auth0/src/test/java/com/auth0/android/provider/PKCETest.java @@ -19,6 +19,8 @@ import org.robolectric.RobolectricTestRunner; import org.robolectric.shadows.ShadowLooper; +import java.net.ConnectException; +import java.net.SocketException; import java.net.SocketTimeoutException; import java.net.UnknownHostException; import java.util.Collections; @@ -129,20 +131,19 @@ public void shouldFailToGetToken() { @Test public void shouldRetryTokenExchangeWhenHostIsUnreachable() { - Request tokenRequest = mock(Request.class); - when(apiClient.token(AUTHORIZATION_CODE, CODE_VERIFIER, REDIRECT_URI)).thenReturn(tokenRequest); - pkce.getToken(AUTHORIZATION_CODE, callback); - ArgumentCaptor callbackCaptor = ArgumentCaptor.forClass(Callback.class); - verify(tokenRequest).start(callbackCaptor.capture()); + assertRetriedThenSucceeds(networkError(new UnknownHostException())); + } - callbackCaptor.getValue().onFailure(networkError(new UnknownHostException())); - ShadowLooper.idleMainLooper(1, TimeUnit.SECONDS); - verify(tokenRequest, times(2)).start(callbackCaptor.capture()); - Credentials credentials = mock(Credentials.class); - callbackCaptor.getValue().onSuccess(credentials); + @Test + public void shouldRetryTokenExchangeWhenConnectionFails() { + assertRetriedThenSucceeds(networkError(new ConnectException())); + } - verify(callback).onSuccess(credentials); - verify(callback, never()).onFailure(any(AuthenticationException.class)); + @Test + public void shouldRetryTokenExchangeWhenEveryRouteFailedToConnect() { + ConnectException error = new ConnectException(); + error.addSuppressed(new ConnectException()); + assertRetriedThenSucceeds(networkError(error)); } @Test @@ -152,37 +153,43 @@ public void shouldFailTokenExchangeWhenHostStaysUnreachable() { pkce.getToken(AUTHORIZATION_CODE, callback); ArgumentCaptor callbackCaptor = ArgumentCaptor.forClass(Callback.class); AuthenticationException error = networkError(new UnknownHostException()); - for (int attempt = 1; attempt <= 4; attempt++) { + for (int attempt = 1; attempt <= 3; attempt++) { verify(tokenRequest, times(attempt)).start(callbackCaptor.capture()); callbackCaptor.getValue().onFailure(error); - ShadowLooper.idleMainLooper(1, TimeUnit.SECONDS); + ShadowLooper.idleMainLooper(2, TimeUnit.SECONDS); } - verify(tokenRequest, times(4)).start(any(Callback.class)); + verify(tokenRequest, times(3)).start(any(Callback.class)); verify(callback).onFailure(error); } @Test - public void shouldNotRetryTokenExchangeWhenRequestMayHaveBeenSent() { - Request tokenRequest = mock(Request.class); - when(apiClient.token(AUTHORIZATION_CODE, CODE_VERIFIER, REDIRECT_URI)).thenReturn(tokenRequest); - pkce.getToken(AUTHORIZATION_CODE, callback); - ArgumentCaptor callbackCaptor = ArgumentCaptor.forClass(Callback.class); - verify(tokenRequest).start(callbackCaptor.capture()); + public void shouldNotRetryTokenExchangeWhenRequestTimedOut() { + assertNotRetried(networkError(new SocketTimeoutException())); + } - AuthenticationException error = networkError(new SocketTimeoutException()); - callbackCaptor.getValue().onFailure(error); - ShadowLooper.idleMainLooper(1, TimeUnit.SECONDS); + @Test + public void shouldNotRetryTokenExchangeWhenConnectionWasReset() { + assertNotRetried(networkError(new SocketException("Connection reset"))); + } - verify(tokenRequest, times(1)).start(any(Callback.class)); - verify(callback).onFailure(error); + @Test + public void shouldNotRetryTokenExchangeWhenOkHttpAlreadyRetriedASentRequest() { + UnknownHostException error = new UnknownHostException(); + error.addSuppressed(new SocketException("Connection reset")); + assertNotRetried(networkError(error)); + } + + @Test + public void shouldNotRetryTokenExchangeOnServerError() { + assertNotRetried(new AuthenticationException("invalid_grant", "Invalid authorization code")); } @Test public void shouldUseConfiguredTokenExchangeRetries() { + pkce = new PKCE(apiClient, new AlgorithmHelperMock(CODE_VERIFIER), REDIRECT_URI, Collections.emptyMap(), 1, 5); Request tokenRequest = mock(Request.class); when(apiClient.token(AUTHORIZATION_CODE, CODE_VERIFIER, REDIRECT_URI)).thenReturn(tokenRequest); - pkce.setNetworkRetry(1, 5000); pkce.getToken(AUTHORIZATION_CODE, callback); ArgumentCaptor callbackCaptor = ArgumentCaptor.forClass(Callback.class); AuthenticationException error = networkError(new UnknownHostException()); @@ -200,16 +207,36 @@ public void shouldUseConfiguredTokenExchangeRetries() { @Test public void shouldNotRetryTokenExchangeWhenRetriesAreDisabled() { + pkce = new PKCE(apiClient, new AlgorithmHelperMock(CODE_VERIFIER), REDIRECT_URI, Collections.emptyMap(), 0, 1); + assertNotRetried(networkError(new UnknownHostException())); + } + + private void assertRetriedThenSucceeds(AuthenticationException error) { + Request tokenRequest = mock(Request.class); + when(apiClient.token(AUTHORIZATION_CODE, CODE_VERIFIER, REDIRECT_URI)).thenReturn(tokenRequest); + pkce.getToken(AUTHORIZATION_CODE, callback); + ArgumentCaptor callbackCaptor = ArgumentCaptor.forClass(Callback.class); + verify(tokenRequest).start(callbackCaptor.capture()); + + callbackCaptor.getValue().onFailure(error); + ShadowLooper.idleMainLooper(2, TimeUnit.SECONDS); + verify(tokenRequest, times(2)).start(callbackCaptor.capture()); + Credentials credentials = mock(Credentials.class); + callbackCaptor.getValue().onSuccess(credentials); + + verify(callback).onSuccess(credentials); + verify(callback, never()).onFailure(any(AuthenticationException.class)); + } + + private void assertNotRetried(AuthenticationException error) { Request tokenRequest = mock(Request.class); when(apiClient.token(AUTHORIZATION_CODE, CODE_VERIFIER, REDIRECT_URI)).thenReturn(tokenRequest); - pkce.setNetworkRetry(0, 1000); pkce.getToken(AUTHORIZATION_CODE, callback); ArgumentCaptor callbackCaptor = ArgumentCaptor.forClass(Callback.class); verify(tokenRequest).start(callbackCaptor.capture()); - AuthenticationException error = networkError(new UnknownHostException()); callbackCaptor.getValue().onFailure(error); - ShadowLooper.idleMainLooper(1, TimeUnit.SECONDS); + ShadowLooper.idleMainLooper(10, TimeUnit.SECONDS); verify(tokenRequest, times(1)).start(any(Callback.class)); verify(callback).onFailure(error); 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 78dfe3e2b..8d4c3c2f3 100644 --- a/auth0/src/test/java/com/auth0/android/provider/WebAuthProviderTest.kt +++ b/auth0/src/test/java/com/auth0/android/provider/WebAuthProviderTest.kt @@ -1067,31 +1067,24 @@ public class WebAuthProviderTest { } @Test - public fun shouldUseDefaultTokenExchangeRetryOnResume() { - val pkce = Mockito.mock(PKCE::class.java) - `when`(pkce.codeChallenge).thenReturn("challenge") - login(account) - .withPKCE(pkce) - .withState("1234567890") - .start(activity, callback) - Assert.assertTrue(resume(createAuthIntent(createHash(null, null, null, null, null, "1234567890", null, null, "1234")))) - verify(pkce).setNetworkRetry(3, 1000L) - verify(pkce).getToken(eq("1234"), any()) + public fun shouldUseDefaultTokenExchangeRetry() { + login(account).start(activity, callback) + + val pkce = restoredManagerState().pkce!! + assertThat(pkce.maxNetworkRetries, `is`(2)) + assertThat(pkce.networkRetryDelaySeconds, `is`(2)) } @Test - public fun shouldUseCustomTokenExchangeRetryOnResume() { - val pkce = Mockito.mock(PKCE::class.java) - `when`(pkce.codeChallenge).thenReturn("challenge") + public fun shouldKeepCustomTokenExchangeRetryAcrossProcessRestore() { login(account) - .withPKCE(pkce) - .withState("1234567890") .withTokenExchangeMaxRetries(5) - .withTokenExchangeRetryDelay(200) + .withTokenExchangeRetryDelay(3) .start(activity, callback) - Assert.assertTrue(resume(createAuthIntent(createHash(null, null, null, null, null, "1234567890", null, null, "1234")))) - verify(pkce).setNetworkRetry(5, 200L) - verify(pkce).getToken(eq("1234"), any()) + + val pkce = restoredManagerState().pkce!! + assertThat(pkce.maxNetworkRetries, `is`(5)) + assertThat(pkce.networkRetryDelaySeconds, `is`(3)) } @Test(expected = IllegalArgumentException::class) @@ -1099,11 +1092,21 @@ public class WebAuthProviderTest { login(account).withTokenExchangeMaxRetries(-1) } + @Test(expected = IllegalArgumentException::class) + public fun shouldRejectTokenExchangeMaxRetriesAboveTen() { + login(account).withTokenExchangeMaxRetries(11) + } + @Test(expected = IllegalArgumentException::class) public fun shouldRejectNegativeTokenExchangeRetryDelay() { login(account).withTokenExchangeRetryDelay(-1) } + private fun restoredManagerState(): OAuthManagerState { + val json = (WebAuthProvider.managerInstance as OAuthManager).toState().serializeToJson() + return OAuthManagerState.deserializeState(json) + } + @Test public fun shouldStartLoginWithValidRequestCode() { val credentials = Mockito.mock( diff --git a/examples/web-auth-configuration.md b/examples/web-auth-configuration.md index 59a4911f0..385e178c4 100644 --- a/examples/web-auth-configuration.md +++ b/examples/web-auth-configuration.md @@ -64,14 +64,14 @@ The URL provided to `.withAuthorizeUrl()` must be a complete and valid HTTPS URL ## Configure the token exchange retry -After the browser redirects back, the SDK exchanges the authorization code for tokens. On some devices, such as Android 15+ or devices with aggressive battery optimization, the OS blocks network access for apps in the background and restores it a moment after the app returns to the foreground. If the exchange fails before reaching Auth0 for this reason, the SDK retries it 3 times, 1000 milliseconds apart. Requests that may have reached Auth0 are never retried, because the authorization code can only be used once. +After the browser redirects back, the SDK exchanges the authorization code for tokens. On some devices, such as Android 15+ or devices with aggressive battery optimization, the OS blocks network access for apps in the background and restores it a moment after the app returns to the foreground. If the exchange fails before reaching Auth0 for this reason, the SDK retries it 2 times, 2 seconds apart. Requests that may have reached Auth0 are never retried, because the authorization code can only be used once. -You can change the number of retries and the delay between them, or pass `0` retries to disable them: +You can change the number of retries (up to 10) and the delay between them in seconds, or pass `0` retries to disable them: ```kotlin WebAuthProvider.login(account) .withTokenExchangeMaxRetries(5) - .withTokenExchangeRetryDelay(500) + .withTokenExchangeRetryDelay(3) .start(this, callback) ``` @@ -81,7 +81,7 @@ WebAuthProvider.login(account) ```java WebAuthProvider.login(account) .withTokenExchangeMaxRetries(5) - .withTokenExchangeRetryDelay(500) + .withTokenExchangeRetryDelay(3) .start(this, callback); ``` From c0031d5362c291a4e49c954b28029087ff2f3924 Mon Sep 17 00:00:00 2001 From: Subhankar Maiti Date: Thu, 12 Nov 2026 14:40:51 +0530 Subject: [PATCH 21/23] fix(webauth): cap token exchange retries at 4 --- auth0/src/main/java/com/auth0/android/provider/PKCE.java | 2 +- .../java/com/auth0/android/provider/WebAuthProvider.kt | 8 ++++---- .../com/auth0/android/provider/WebAuthProviderTest.kt | 8 ++++---- examples/web-auth-configuration.md | 6 +++--- 4 files changed, 12 insertions(+), 12 deletions(-) diff --git a/auth0/src/main/java/com/auth0/android/provider/PKCE.java b/auth0/src/main/java/com/auth0/android/provider/PKCE.java index 42641f5db..ca8b1fdc8 100644 --- a/auth0/src/main/java/com/auth0/android/provider/PKCE.java +++ b/auth0/src/main/java/com/auth0/android/provider/PKCE.java @@ -169,7 +169,7 @@ private boolean shouldRetry(@NonNull AuthenticationException error, int attempt) // OkHttp attaches the failures it already recovered from as suppressed exceptions, and any of // those that isn't a DNS or connect failure means the request may have been sent. - private static boolean isUnsentRequestError(@Nullable Throwable error) { + private boolean isUnsentRequestError(@Nullable Throwable error) { if (!(error instanceof UnknownHostException || error instanceof ConnectException)) { return false; } 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 bdea1c459..87e48e8e5 100644 --- a/auth0/src/main/java/com/auth0/android/provider/WebAuthProvider.kt +++ b/auth0/src/main/java/com/auth0/android/provider/WebAuthProvider.kt @@ -37,7 +37,7 @@ public object WebAuthProvider { 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 const val MAX_TOKEN_EXCHANGE_RETRIES = 10 + private const val TOKEN_EXCHANGE_MAX_RETRIES_LIMIT = 4 private val callbacks = CopyOnWriteArraySet>() private val parCallbacks = @@ -589,12 +589,12 @@ public object WebAuthProvider { * network access yet after it was in the background. Requests that may have reached Auth0 * are never retried. Defaults to 2. Pass 0 to disable retries. * - * @param maxRetries the maximum number of retries, from 0 to 10. + * @param maxRetries the maximum number of retries, from 0 to 4. * @return the current builder instance */ public fun withTokenExchangeMaxRetries(maxRetries: Int): Builder { - require(maxRetries in 0..MAX_TOKEN_EXCHANGE_RETRIES) { - "maxRetries must be between 0 and $MAX_TOKEN_EXCHANGE_RETRIES" + require(maxRetries in 0..TOKEN_EXCHANGE_MAX_RETRIES_LIMIT) { + "maxRetries must be between 0 and $TOKEN_EXCHANGE_MAX_RETRIES_LIMIT" } this.tokenExchangeMaxRetries = maxRetries return this 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 8d4c3c2f3..4670dff36 100644 --- a/auth0/src/test/java/com/auth0/android/provider/WebAuthProviderTest.kt +++ b/auth0/src/test/java/com/auth0/android/provider/WebAuthProviderTest.kt @@ -1078,12 +1078,12 @@ public class WebAuthProviderTest { @Test public fun shouldKeepCustomTokenExchangeRetryAcrossProcessRestore() { login(account) - .withTokenExchangeMaxRetries(5) + .withTokenExchangeMaxRetries(4) .withTokenExchangeRetryDelay(3) .start(activity, callback) val pkce = restoredManagerState().pkce!! - assertThat(pkce.maxNetworkRetries, `is`(5)) + assertThat(pkce.maxNetworkRetries, `is`(4)) assertThat(pkce.networkRetryDelaySeconds, `is`(3)) } @@ -1093,8 +1093,8 @@ public class WebAuthProviderTest { } @Test(expected = IllegalArgumentException::class) - public fun shouldRejectTokenExchangeMaxRetriesAboveTen() { - login(account).withTokenExchangeMaxRetries(11) + public fun shouldRejectTokenExchangeMaxRetriesAboveFour() { + login(account).withTokenExchangeMaxRetries(5) } @Test(expected = IllegalArgumentException::class) diff --git a/examples/web-auth-configuration.md b/examples/web-auth-configuration.md index 385e178c4..0c075f940 100644 --- a/examples/web-auth-configuration.md +++ b/examples/web-auth-configuration.md @@ -66,11 +66,11 @@ The URL provided to `.withAuthorizeUrl()` must be a complete and valid HTTPS URL After the browser redirects back, the SDK exchanges the authorization code for tokens. On some devices, such as Android 15+ or devices with aggressive battery optimization, the OS blocks network access for apps in the background and restores it a moment after the app returns to the foreground. If the exchange fails before reaching Auth0 for this reason, the SDK retries it 2 times, 2 seconds apart. Requests that may have reached Auth0 are never retried, because the authorization code can only be used once. -You can change the number of retries (up to 10) and the delay between them in seconds, or pass `0` retries to disable them: +You can change the number of retries (up to 4) and the delay between them in seconds, or pass `0` retries to disable them: ```kotlin WebAuthProvider.login(account) - .withTokenExchangeMaxRetries(5) + .withTokenExchangeMaxRetries(4) .withTokenExchangeRetryDelay(3) .start(this, callback) ``` @@ -80,7 +80,7 @@ WebAuthProvider.login(account) ```java WebAuthProvider.login(account) - .withTokenExchangeMaxRetries(5) + .withTokenExchangeMaxRetries(4) .withTokenExchangeRetryDelay(3) .start(this, callback); ``` From 1a44d0f27be5bd550afb5298e8c9a2713c033f62 Mon Sep 17 00:00:00 2001 From: Subhankar Maiti Date: Thu, 12 Nov 2026 15:53:48 +0530 Subject: [PATCH 22/23] Release 4.3.1 --- .version | 2 +- CHANGELOG.md | 9 +++++++++ README.md | 2 +- 3 files changed, 11 insertions(+), 2 deletions(-) diff --git a/.version b/.version index 819113891..ecedc98d1 100644 --- a/.version +++ b/.version @@ -1 +1 @@ -4.3.0 \ No newline at end of file +4.3.1 \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index dfba59f26..37db8070c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # Change Log +## [4.3.1](https://github.com/auth0/Auth0.Android/tree/4.3.1) (2026-11-12) +[Full Changelog](https://github.com/auth0/Auth0.Android/compare/4.3.0...4.3.1) + +**Changed** +- docs(myaccount): mark the My Account Profile API as Beta [\#1124](https://github.com/auth0/Auth0.Android/pull/1124) ([utkrishtsahu](https://github.com/utkrishtsahu)) + +**Fixed** +- fix(webauth): retry code exchange while network is still blocked, make it configurable [\#1125](https://github.com/auth0/Auth0.Android/pull/1125) ([subhankarmaiti](https://github.com/subhankarmaiti)) + ## [4.3.0](https://github.com/auth0/Auth0.Android/tree/4.3.0) (2026-10-30) [Full Changelog](https://github.com/auth0/Auth0.Android/compare/4.2.1...4.3.0) diff --git a/README.md b/README.md index ba4b5746e..9f826fa9a 100644 --- a/README.md +++ b/README.md @@ -51,7 +51,7 @@ To install Auth0.Android with [Gradle](https://gradle.org/), simply add the foll ```gradle dependencies { - implementation 'com.auth0.android:auth0:4.3.0' + implementation 'com.auth0.android:auth0:4.3.1' } ``` From c5db6cd117a9407bfb3921b6b9fa9faab70c92a8 Mon Sep 17 00:00:00 2001 From: Subhankar Maiti Date: Thu, 12 Nov 2026 16:10:18 +0530 Subject: [PATCH 23/23] chore : Added the JDK setup to the rl-scanner job --- .github/workflows/release.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0761b88f0..6036d5d45 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -20,6 +20,9 @@ jobs: with: fetch-depth: 0 + - name: Configure CI + uses: ./.github/actions/setup + - name: Build with gradle run: ./gradlew :auth0:assembleRelease