diff --git a/.github/workflows/rl-scanner.yml b/.github/workflows/rl-scanner.yml deleted file mode 100644 index e69de29bb..000000000 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 6b82f5cdc..91a6a5e57 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -6,7 +6,6 @@ on: pull_request: branches: - main - - v4_development push: branches: - main diff --git a/.gitignore b/.gitignore index 4b9d13b91..af4cb7276 100644 --- a/.gitignore +++ b/.gitignore @@ -96,4 +96,7 @@ gen-external-apklibs .TemporaryItems .Trashes -version.txt \ No newline at end of file +version.txt + +plans/ +docs/ \ No newline at end of file diff --git a/README.md b/README.md index c0ff289b3..6c8285544 100644 --- a/README.md +++ b/README.md @@ -26,9 +26,9 @@ ### Requirements -Android API version 31 or later and Java 17+. +Android API version 26 or later and Java 17+. -> :warning: Applications targeting Android SDK version 30 (`targetSdkVersion = 30`) and below should use version 2.9.0. +> :warning: Applications with `minSdkVersion` below 26 should continue using the v3 line of the SDK. Here’s what you need in `build.gradle` to target Java 17 bytecode for Android and Kotlin plugins respectively. @@ -120,7 +120,7 @@ android { compileSdkVersion 36 defaultConfig { applicationId "com.auth0.samples" - minSdkVersion 24 + minSdkVersion 26 targetSdkVersion 36 //... 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 81a1ced6f..63e6f922c 100644 --- a/auth0/src/main/java/com/auth0/android/myaccount/MyAccountAPIClient.kt +++ b/auth0/src/main/java/com/auth0/android/myaccount/MyAccountAPIClient.kt @@ -268,6 +268,9 @@ public class MyAccountAPIClient @VisibleForTesting(otherwise = VisibleForTesting /** * Retrieves a detailed list of authentication methods belonging to the user. * + * ## Scopes Required + * + * `read:me:authentication_methods` * * ## Usage * diff --git a/auth0/src/main/java/com/auth0/android/provider/CustomTabsController.java b/auth0/src/main/java/com/auth0/android/provider/CustomTabsController.java index 74718df39..86dd047c4 100644 --- a/auth0/src/main/java/com/auth0/android/provider/CustomTabsController.java +++ b/auth0/src/main/java/com/auth0/android/provider/CustomTabsController.java @@ -208,7 +208,7 @@ private void launchAsAuthTab(@NonNull Context context, @NonNull Uri uri, @NonNul builder.setSession(authSession); } AuthTabIntent authTabIntent = builder.build(); - authTabIntent.launch(authTabLauncher, uri, scheme); + threadSwitcher.mainThread(() -> authTabIntent.launch(authTabLauncher, uri, scheme)); } private void launchAsDefault(Context context, Uri uri) { diff --git a/auth0/src/test/java/com/auth0/android/authentication/storage/SecureCredentialsManagerTest.kt b/auth0/src/test/java/com/auth0/android/authentication/storage/SecureCredentialsManagerTest.kt index 67c328613..1897f6cc5 100644 --- a/auth0/src/test/java/com/auth0/android/authentication/storage/SecureCredentialsManagerTest.kt +++ b/auth0/src/test/java/com/auth0/android/authentication/storage/SecureCredentialsManagerTest.kt @@ -10,6 +10,7 @@ import com.auth0.android.authentication.AuthenticationAPIClient import com.auth0.android.authentication.AuthenticationException import com.auth0.android.authentication.storage.BaseCredentialsManager.Companion.DEFAULT_MIN_TTL import com.auth0.android.callback.Callback +import com.auth0.android.dpop.DPoPException import com.auth0.android.dpop.DPoPKeyStore import com.auth0.android.dpop.DPoPUtil import com.auth0.android.dpop.FakeECPrivateKey @@ -116,6 +117,15 @@ public class SecureCredentialsManagerTest { private val serialExecutor = Executor { runnable -> runnable.run() } + // Executors spun up by the concurrency tests, shut down in tearDown() so no + // non-daemon threads leak and cause the suite to hang or flake afterwards. + private val managedExecutors = mutableListOf() + + private fun track(executor: T): T { + managedExecutors.add(executor) + return executor + } + private val credentialsCaptor: KArgumentCaptor = argumentCaptor() private val exceptionCaptor: KArgumentCaptor = argumentCaptor() @@ -2122,8 +2132,8 @@ public class SecureCredentialsManagerTest { Mockito.`when`(client.renewAuth(refreshToken = "refreshToken")).thenReturn(request) Mockito.`when`(request.execute()).thenReturn(renewedCredentials) - val sharedExecutor = Executors.newSingleThreadExecutor() - val callerPool: ExecutorService = Executors.newFixedThreadPool(3) + val sharedExecutor = track(Executors.newSingleThreadExecutor()) + val callerPool: ExecutorService = track(Executors.newFixedThreadPool(3)) val latch = CountDownLatch(3) val context: Context = Robolectric.buildActivity(Activity::class.java).create().start().resume().get() @@ -2191,8 +2201,8 @@ public class SecureCredentialsManagerTest { Mockito.`when`(client.renewAuth(refreshToken = "refreshToken")).thenReturn(request) Mockito.`when`(request.execute()).thenReturn(renewedCredentials) - val singleThreadExecutor = Executors.newSingleThreadExecutor() - val callerPool = Executors.newFixedThreadPool(3) + val singleThreadExecutor = track(Executors.newSingleThreadExecutor()) + val callerPool = track(Executors.newFixedThreadPool(3)) val latch = CountDownLatch(3) val context: Context = Robolectric.buildActivity(Activity::class.java).create().start().resume().get() @@ -2247,8 +2257,8 @@ public class SecureCredentialsManagerTest { "scope" ) - val singleThreadExecutor = Executors.newSingleThreadExecutor() - val callerPool = Executors.newFixedThreadPool(3) + val singleThreadExecutor = track(Executors.newSingleThreadExecutor()) + val callerPool = track(Executors.newFixedThreadPool(3)) val latch = CountDownLatch(3) val context: Context = Robolectric.buildActivity(Activity::class.java).create().start().resume().get() @@ -2309,8 +2319,8 @@ public class SecureCredentialsManagerTest { Mockito.`when`(client.renewAuth(refreshToken = "refreshToken")).thenReturn(request) Mockito.`when`(request.execute()).thenReturn(renewedCredentials) - val singleThreadExecutor = Executors.newSingleThreadExecutor() - val callerPool = Executors.newFixedThreadPool(2) + val singleThreadExecutor = track(Executors.newSingleThreadExecutor()) + val callerPool = track(Executors.newFixedThreadPool(2)) val latch = CountDownLatch(2) val context: Context = Robolectric.buildActivity(Activity::class.java).create().start().resume().get() @@ -2380,6 +2390,22 @@ public class SecureCredentialsManagerTest { manager.clearAll() verify(storage).removeAll() verify(crypto).deleteAllKeys() + verify(mockDPoPKeyStore).deleteKeyPair() + Assert.assertFalse(manager.isBiometricSessionValid()) + } + + @Test + public fun shouldClearStorageAndKeysEvenWhenDPoPKeyDeletionFails() { + whenever(mockDPoPKeyStore.deleteKeyPair()) + .thenThrow(DPoPException.KEY_STORE_ERROR) + + // clearAll swallows DPoP deletion errors, so it must not throw and the + // storage / crypto / biometric cleanup must still complete. + manager.clearAll() + + verify(storage).removeAll() + verify(crypto).deleteAllKeys() + verify(mockDPoPKeyStore).deleteKeyPair() Assert.assertFalse(manager.isBiometricSessionValid()) } @@ -4455,7 +4481,11 @@ public class SecureCredentialsManagerTest { val renewedCredentials = Credentials("newId", "newAccess", "newType", null, newDate, "newScope") Mockito.`when`(request.execute()).thenReturn(renewedCredentials) + // First encryption (base credentials save) succeeds; the second + // (renewed API credentials save) throws, so the failure under test is + // the API-credential persistence path, not the preceding base save. Mockito.`when`(crypto.encrypt(any())) + .thenAnswer { it.arguments[0] as ByteArray } .thenThrow(CryptoException("CryptoException is thrown")) manager.continueGetApiCredentials( @@ -4475,6 +4505,8 @@ public class SecureCredentialsManagerTest { @After public fun tearDown() { DPoPUtil.keyStore = DPoPKeyStore() + managedExecutors.forEach { it.shutdownNow() } + managedExecutors.clear() } private fun prepareJwtDecoderMock(expiresAt: Date?) { diff --git a/auth0/src/test/java/com/auth0/android/request/internal/SSOCredentialsDeserializerTest.kt b/auth0/src/test/java/com/auth0/android/request/internal/SSOCredentialsDeserializerTest.kt index 31a5aa5b1..0a5a99e94 100644 --- a/auth0/src/test/java/com/auth0/android/request/internal/SSOCredentialsDeserializerTest.kt +++ b/auth0/src/test/java/com/auth0/android/request/internal/SSOCredentialsDeserializerTest.kt @@ -3,10 +3,12 @@ package com.auth0.android.request.internal import com.auth0.android.result.CredentialsMock import com.auth0.android.result.SSOCredentials import com.google.gson.Gson +import com.google.gson.JsonParseException import org.hamcrest.CoreMatchers import org.hamcrest.MatcherAssert import org.hamcrest.Matchers import org.hamcrest.core.Is +import org.junit.Assert import org.junit.Before import org.junit.Test @@ -63,6 +65,50 @@ public class SSOCredentialsDeserializerTest { MatcherAssert.assertThat(credentials.refreshToken, Is.`is`(CoreMatchers.nullValue())) } + @Test + @Throws(Exception::class) + public fun shouldThrowWhenJsonIsNotObject() { + val exception = Assert.assertThrows(JsonParseException::class.java) { + gson.getAdapter(SSOCredentials::class.java).fromJson("[]") + } + MatcherAssert.assertThat( + exception.message, + Is.`is`("sso credentials json is not a valid json object") + ) + } + + @Test + @Throws(Exception::class) + public fun shouldThrowWhenJsonObjectIsEmpty() { + val exception = Assert.assertThrows(JsonParseException::class.java) { + gson.getAdapter(SSOCredentials::class.java).fromJson("{}") + } + MatcherAssert.assertThat( + exception.message, + Is.`is`("sso credentials json is not a valid json object") + ) + } + + @Test + @Throws(Exception::class) + public fun shouldThrowWhenExpiresInIsMissing() { + val json = """ + { + "access_token": "session-transfer-token", + "id_token": "id-token-value", + "issued_token_type": "urn:auth0:params:oauth:token-type:session-transfer-token", + "token_type": "N_A" + } + """.trimIndent() + val exception = Assert.assertThrows(JsonParseException::class.java) { + gson.getAdapter(SSOCredentials::class.java).fromJson(json) + } + MatcherAssert.assertThat( + exception.message, + Is.`is`("Missing the required property expires_in") + ) + } + private fun generateSSOCredentialsJSON(): String { return """ {