diff --git a/libs/SalesforceSDK/res/values/sf__strings.xml b/libs/SalesforceSDK/res/values/sf__strings.xml index dd759506a4..5ebe981dd5 100644 --- a/libs/SalesforceSDK/res/values/sf__strings.xml +++ b/libs/SalesforceSDK/res/values/sf__strings.xml @@ -11,6 +11,7 @@ Authentication only allowed from managed device. JWT authentication error. Please try again. Lightning URLs are not supported for OAuth code exchange. Use your My Domain URL instead. + This app could not be verified. Contact support. SSL error: %s. diff --git a/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/LoginActivity.kt b/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/LoginActivity.kt index 4069244d45..481582ab82 100644 --- a/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/LoginActivity.kt +++ b/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/LoginActivity.kt @@ -106,6 +106,7 @@ import com.salesforce.androidsdk.R.color.sf__background import com.salesforce.androidsdk.R.color.sf__background_dark import com.salesforce.androidsdk.R.drawable.sf__action_back import com.salesforce.androidsdk.R.string.cannot_use_another_apps_login_qr_code +import com.salesforce.androidsdk.R.string.sf__app_blocked_error import com.salesforce.androidsdk.R.string.sf__biometric_opt_in_title import com.salesforce.androidsdk.R.string.sf__generic_authentication_error_title import com.salesforce.androidsdk.R.string.sf__jwt_authentication_error @@ -125,6 +126,8 @@ import com.salesforce.androidsdk.app.Features.FEATURE_WELCOME_DISCOVERY_LOGIN import com.salesforce.androidsdk.app.SalesforceSDKManager import com.salesforce.androidsdk.app.SalesforceSDKManager.Theme.DARK import com.salesforce.androidsdk.auth.HttpAccess +import com.salesforce.androidsdk.auth.OAuth2.CLIENT_BLOCKED_ERROR +import com.salesforce.androidsdk.auth.OAuth2.CLIENT_BLOCKED_RETRY_ERROR import com.salesforce.androidsdk.auth.OAuth2.OAuthFailedException import com.salesforce.androidsdk.auth.OAuth2.TokenEndpointResponse import com.salesforce.androidsdk.auth.OAuth2.swapJWTForTokens @@ -228,7 +231,7 @@ open class LoginActivity : FragmentActivity() { } // Private variables - private var baseUserAgentString = ""; + private var baseUserAgentString = "" private var wasBackgrounded = false private var accountAuthenticatorResponse: AccountAuthenticatorResponse? = null private var accountAuthenticatorResult: Bundle? = null @@ -584,6 +587,9 @@ open class LoginActivity : FragmentActivity() { ) viewModel.clearCookies() + val isClientBlocked = e is OAuthFailedException + && (e.tokenErrorResponse.error == CLIENT_BLOCKED_ERROR + || e.tokenErrorResponse.error == CLIENT_BLOCKED_RETRY_ERROR) val isLightningTokenEndpointFailure = e is OAuthFailedException && e.tokenErrorResponse.error == "unsupported_grant_type" && viewModel.selectedServer.value?.contains(".lightning.") == true @@ -592,11 +598,12 @@ open class LoginActivity : FragmentActivity() { } // Displays the error in a toast, clears cookies and reloads the login page runOnUiThread { - if (isLightningTokenEndpointFailure) { - makeText(this, getString(sf__lightning_url_code_exchange_error), LENGTH_LONG).show() - } else { - makeText(this, "$error : $errorDesc", LENGTH_LONG).show() + val message = when { + isClientBlocked -> getString(sf__app_blocked_error) + isLightningTokenEndpointFailure -> getString(sf__lightning_url_code_exchange_error) + else -> "$error : $errorDesc" } + makeText(this, message, LENGTH_LONG).show() viewModel.reloadWebView() } } @@ -936,12 +943,12 @@ open class LoginActivity : FragmentActivity() { // Set welcome discovery feature flag if applicable if (isLoginWithWelcomeDiscovery(intent)) { SalesforceSDKManager.getInstance() - .registerUsedAppFeature(FEATURE_WELCOME_DISCOVERY_LOGIN); + .registerUsedAppFeature(FEATURE_WELCOME_DISCOVERY_LOGIN) } else { SalesforceSDKManager.getInstance().unregisterUsedAppFeature( FEATURE_WELCOME_DISCOVERY_LOGIN - ); + ) } // Re-apply user agent to WebView @@ -1125,7 +1132,7 @@ open class LoginActivity : FragmentActivity() { loginHint = uri.getQueryParameter(SALESFORCE_WELCOME_DISCOVERY_MOBILE_CALLBACK_URL_QUERY_PARAMETER_KEY_LOGIN_HINT) ?: return false, loginHost = uri.getQueryParameter(SALESFORCE_WELCOME_DISCOVERY_MOBILE_CALLBACK_URL_QUERY_PARAMETER_KEY_MY_DOMAIN)?.toUri()?.host ?: return false ) - return true + true } else false } @@ -1534,7 +1541,7 @@ open class LoginActivity : FragmentActivity() { /** * Determines if the provided URL has the Salesforce Welcome Discovery * path. - * @param url The URL to examine for the Salesforce Welcome Discovery + * @param uri The URL to examine for the Salesforce Welcome Discovery * path * @return Boolean true if the URL has the Salesforce Welcome Discovery * path or false otherwise @@ -1549,7 +1556,7 @@ open class LoginActivity : FragmentActivity() { * Determines if the provided URL has the Salesforce Welcome Discovery * path and parameters for mobile callback. The client id (consumer * key) of the URL must match the boot config's consumer key. - * @param url The URL to examine for the Salesforce Welcome Discovery + * @param uri The URL to examine for the Salesforce Welcome Discovery * path and parameters for mobile callback * @return Boolean true if the URL has the Salesforce Welcome Discovery * path and parameters for mobile callback and matches the boot config's @@ -1575,7 +1582,7 @@ open class LoginActivity : FragmentActivity() { * Determines if the provided URL has the Salesforce Welcome Discovery * path and parameters for mobile callback. The client id (consumer * key) of the URL must match the boot config's consumer key. - * @param url The URL to examine for the Salesforce Welcome Discovery + * @param uri The URL to examine for the Salesforce Welcome Discovery * path and parameters for mobile callback * @return Boolean true if the URL has the Salesforce Welcome Discovery * path and parameters for mobile callback and matches the boot config's @@ -1654,7 +1661,7 @@ open class LoginActivity : FragmentActivity() { * Activity result callback for the "Login for Admin" custom tab. */ @VisibleForTesting - internal inner class AdminCustomTabActivityResult : ActivityResultCallback { + internal class AdminCustomTabActivityResult : ActivityResultCallback { override fun onActivityResult(result: ActivityResult) { // Intentional no-op: keep the existing WebView visible on cancel. } diff --git a/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/auth/LoginViewModelMockTest.kt b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/auth/LoginViewModelMockTest.kt index e8265a6051..56e77380ce 100644 --- a/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/auth/LoginViewModelMockTest.kt +++ b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/auth/LoginViewModelMockTest.kt @@ -35,7 +35,13 @@ import com.salesforce.androidsdk.accounts.UserAccountBuilder import com.salesforce.androidsdk.accounts.UserAccountManager import com.salesforce.androidsdk.accounts.UserAccountTest import com.salesforce.androidsdk.app.SalesforceSDKManager +import com.salesforce.androidsdk.auth.OAuth2.CLIENT_BLOCKED_ERROR +import com.salesforce.androidsdk.auth.OAuth2.CLIENT_BLOCKED_RETRY_ERROR +import com.salesforce.androidsdk.auth.OAuth2.OAuthFailedException +import com.salesforce.androidsdk.auth.OAuth2.TIMESTAMP_FORMAT import com.salesforce.androidsdk.auth.OAuth2.TokenEndpointResponse +import com.salesforce.androidsdk.auth.OAuth2.TokenErrorResponse +import com.salesforce.androidsdk.auth.OAuth2.exchangeCode import com.salesforce.androidsdk.config.BootConfig import com.salesforce.androidsdk.config.OAuthConfig import com.salesforce.androidsdk.security.BiometricAuthenticationManager @@ -60,6 +66,7 @@ import org.junit.Before import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith +import java.io.IOException /** * Tests for LoginViewModel that require mocking. @@ -87,11 +94,11 @@ class LoginViewModelMockTest { // Create view model after mocking viewModel = LoginViewModel(bootConfig) - + // This is required for the LiveData to actually update during the test viewModel.selectedServer.observeForever { } viewModel.loginUrl.observeForever { } - + // Give the LiveData sources time to propagate Thread.sleep(100) } @@ -106,7 +113,7 @@ class LoginViewModelMockTest { @Test fun onAuthFlowComplete_CallsAuthenticationUtilities_WithCorrectParameters() = runBlocking { // Mock the AuthenticationUtilities.onAuthFlowComplete function - + // Mock the function to do nothing (just capture parameters) coEvery { onAuthFlowComplete( @@ -132,20 +139,20 @@ class LoginViewModelMockTest { handleDuplicateUserAccount = any(), ) } returns Unit - + // Create test data val testServer = "https://test.salesforce.com" val mockTokenResponse = mockk(relaxed = true) val mockOnError: (String, String?, Throwable?) -> Unit = mockk(relaxed = true) val mockOnSuccess: (UserAccount) -> Unit = mockk(relaxed = true) - + // Set up the view model state viewModel.selectedServer.value = testServer Thread.sleep(100) - + // Call the method under test viewModel.onAuthFlowComplete(mockTokenResponse, mockOnError, mockOnSuccess) - + // Verify AuthenticationUtilities.onAuthFlowComplete was called with correct parameters coVerify { onAuthFlowComplete( @@ -176,7 +183,7 @@ class LoginViewModelMockTest { @Test fun onAuthFlowComplete_CallsAuthenticationUtilitiesSuccessfully() = runBlocking { // Mock the AuthenticationUtilities.onAuthFlowComplete function - + coEvery { onAuthFlowComplete( tokenResponse = any(), @@ -201,15 +208,15 @@ class LoginViewModelMockTest { handleDuplicateUserAccount = any(), ) } returns Unit - + val mockTokenResponse = mockk(relaxed = true) val mockOnError: (String, String?, Throwable?) -> Unit = mockk(relaxed = true) val mockOnSuccess: (UserAccount) -> Unit = mockk(relaxed = true) - + // Set up the view model state viewModel.selectedServer.value = "https://test.salesforce.com" Thread.sleep(100) - + // Call the method under test viewModel.onAuthFlowComplete(mockTokenResponse, mockOnError, mockOnSuccess) @@ -244,7 +251,7 @@ class LoginViewModelMockTest { @Test fun onAuthFlowComplete_ResetsAuthCodeForJwtFlow() = runBlocking { // Mock the AuthenticationUtilities.onAuthFlowComplete function - + coEvery { onAuthFlowComplete( tokenResponse = any(), @@ -269,22 +276,22 @@ class LoginViewModelMockTest { handleDuplicateUserAccount = any(), ) } returns Unit - + val mockTokenResponse = mockk(relaxed = true) val mockOnError: (String, String?, Throwable?) -> Unit = mockk(relaxed = true) val mockOnSuccess: (UserAccount) -> Unit = mockk(relaxed = true) - + // Set up the view model state with JWT flow viewModel.selectedServer.value = "https://test.salesforce.com" viewModel.authCodeForJwtFlow = "test_jwt_auth_code" Thread.sleep(100) - + // Verify authCodeForJwtFlow is set assertNotNull("authCodeForJwtFlow should be set before call", viewModel.authCodeForJwtFlow) - + // Call the method under test viewModel.onAuthFlowComplete(mockTokenResponse, mockOnError, mockOnSuccess) - + // Verify authCodeForJwtFlow is reset to null assertNull("authCodeForJwtFlow should be null after call", viewModel.authCodeForJwtFlow) } @@ -292,7 +299,7 @@ class LoginViewModelMockTest { @Test fun onAuthFlowComplete_UsesEmptyString_WhenSelectedServerIsNull() = runBlocking { // Mock the AuthenticationUtilities.onAuthFlowComplete function - + coEvery { onAuthFlowComplete( tokenResponse = any(), @@ -317,18 +324,18 @@ class LoginViewModelMockTest { handleDuplicateUserAccount = any(), ) } returns Unit - + val mockTokenResponse = mockk(relaxed = true) val mockOnError: (String, String?, Throwable?) -> Unit = mockk(relaxed = true) val mockOnSuccess: (UserAccount) -> Unit = mockk(relaxed = true) - + // Set selectedServer to null viewModel.selectedServer.value = null Thread.sleep(100) - + // Call the method under test viewModel.onAuthFlowComplete(mockTokenResponse, mockOnError, mockOnSuccess) - + // Verify empty string is used when selectedServer is null coVerify { onAuthFlowComplete( @@ -362,10 +369,10 @@ class LoginViewModelMockTest { val testCode = "test_auth_code_123" val mockOnError: (String, String?, Throwable?) -> Unit = mockk(relaxed = true) val mockOnSuccess: (UserAccount) -> Unit = mockk(relaxed = true) - + // Create a spy of viewModel to verify and mock doCodeExchange val spyViewModel = spyk(viewModel) - + // Mock doCodeExchange to prevent actual execution coEvery { spyViewModel.doCodeExchange(any(), any(), any(), any(), any()) @@ -374,13 +381,13 @@ class LoginViewModelMockTest { // Set up the view model state spyViewModel.selectedServer.value = testServer Thread.sleep(100) - + // Call the method under test spyViewModel.onWebServerFlowComplete(testCode, mockOnError, mockOnSuccess) - + // Give time for the coroutine to execute Thread.sleep(200) - + // Verify doCodeExchange was called with correct parameters coVerify { spyViewModel.doCodeExchange( @@ -445,10 +452,10 @@ class LoginViewModelMockTest { val testCode = "test_auth_code_123" val mockOnError: (String, String?, Throwable?) -> Unit = mockk(relaxed = true) val mockOnSuccess: (UserAccount) -> Unit = mockk(relaxed = true) - + // Create a spy of viewModel to verify and mock doCodeExchange val spyViewModel = spyk(viewModel) - + // Mock doCodeExchange to prevent actual execution coEvery { spyViewModel.doCodeExchange(any(), any(), any(), any(), any()) @@ -457,10 +464,10 @@ class LoginViewModelMockTest { // Set up front door bridge spyViewModel.loginWithFrontDoorBridgeUrl(frontDoorUrl, frontDoorVerifier) Thread.sleep(100) - + // Call the method under test spyViewModel.onWebServerFlowComplete(testCode, mockOnError, mockOnSuccess) - + // Give time for the coroutine to execute Thread.sleep(200) @@ -481,10 +488,10 @@ class LoginViewModelMockTest { val testServer = "https://test.salesforce.com" val mockOnError: (String, String?, Throwable?) -> Unit = mockk(relaxed = true) val mockOnSuccess: (UserAccount) -> Unit = mockk(relaxed = true) - + // Create a spy of viewModel to verify and mock doCodeExchange val spyViewModel = spyk(viewModel) - + // Mock doCodeExchange to prevent actual execution coEvery { spyViewModel.doCodeExchange(any(), any(), any(), any(), any()) @@ -493,10 +500,10 @@ class LoginViewModelMockTest { // Set up the view model state spyViewModel.selectedServer.value = testServer Thread.sleep(100) - + // Call with null code spyViewModel.onWebServerFlowComplete(null, mockOnError, mockOnSuccess) - + // Give time for the coroutine to execute Thread.sleep(200) @@ -589,7 +596,7 @@ class LoginViewModelMockTest { // Create a spy of viewModel to verify and mock doCodeExchange val spyViewModel = spyk(viewModel) - + // Mock doCodeExchange to prevent actual execution coEvery { spyViewModel.doCodeExchange(any(), any(), any(), any(), any()) @@ -634,10 +641,10 @@ class LoginViewModelMockTest { val spyViewModel = spyk(viewModel) // Force OAuth2 class initialization before mocking to avoid ExceptionInInitializerError - OAuth2.TIMESTAMP_FORMAT + TIMESTAMP_FORMAT mockkStatic(OAuth2::class) every { - OAuth2.exchangeCode(any(), any(), any(), any(), any(), any()) + exchangeCode(any(), any(), any(), any(), any(), any()) } returns mockTokenResponse // Mock doCodeExchange to prevent actual execution @@ -682,10 +689,10 @@ class LoginViewModelMockTest { val spyViewModel = spyk(viewModel) // Force OAuth2 class initialization before mocking to avoid ExceptionInInitializerError - OAuth2.TIMESTAMP_FORMAT + TIMESTAMP_FORMAT mockkStatic(OAuth2::class) every { - OAuth2.exchangeCode(any(), any(), any(), any(), any(), any()) + exchangeCode(any(), any(), any(), any(), any(), any()) } returns mockTokenResponse // Mock doCodeExchange to prevent actual execution @@ -736,10 +743,10 @@ class LoginViewModelMockTest { val mockTokenResponse: TokenEndpointResponse = mockk(relaxed = true) // Force OAuth2 class initialization before mocking to avoid ExceptionInInitializerError - OAuth2.TIMESTAMP_FORMAT + TIMESTAMP_FORMAT mockkStatic(OAuth2::class) every { - OAuth2.exchangeCode(any(), any(), any(), any(), any(), any()) + exchangeCode(any(), any(), any(), any(), any(), any()) } returns mockTokenResponse // Spy so we can short-circuit account creation, leaving exchangeCode as the observable. @@ -774,7 +781,7 @@ class LoginViewModelMockTest { // Token exchange must be performed with MIGRATION credentials. verify { - OAuth2.exchangeCode( + exchangeCode( /* httpAccessor = */ any(), /* loginServer = */ any(), /* clientId = */ migrationConsumerKey, @@ -787,6 +794,98 @@ class LoginViewModelMockTest { // endregion + // region doCodeExchange Error Path Tests + + private fun setupExchangeCodeMock(throws: Throwable) { + TIMESTAMP_FORMAT + mockkStatic(OAuth2::class) + every { + exchangeCode(any(), any(), any(), any(), any(), any()) + } throws throws + } + + @Test + fun doCodeExchange_whenExchangeCodeThrowsClientBlocked_callsOnAuthFlowError() = runBlocking { + val mockOnError: (String, String?, Throwable?) -> Unit = mockk(relaxed = true) + val mockOnSuccess: (UserAccount) -> Unit = mockk(relaxed = true) + val spyViewModel = spyk(viewModel) + + val tokenErrorResponse = mockk(relaxed = true) + tokenErrorResponse.error = CLIENT_BLOCKED_ERROR + tokenErrorResponse.errorDescription = "App is blocked" + val oauthException = OAuthFailedException(tokenErrorResponse, 403) + setupExchangeCodeMock(oauthException) + + spyViewModel.selectedServer.value = "https://test.salesforce.com" + spyViewModel.doCodeExchange("test_auth_code", mockOnError, mockOnSuccess) + + verify { mockOnError("Token Request Error", any(), oauthException) } + verify(exactly = 0) { mockOnSuccess(any()) } + } + + @Test + fun doCodeExchange_whenExchangeCodeThrowsClientBlockedRetry_callsOnAuthFlowError() = runBlocking { + val mockOnError: (String, String?, Throwable?) -> Unit = mockk(relaxed = true) + val mockOnSuccess: (UserAccount) -> Unit = mockk(relaxed = true) + val spyViewModel = spyk(viewModel) + + val tokenErrorResponse = mockk(relaxed = true) + tokenErrorResponse.error = CLIENT_BLOCKED_RETRY_ERROR + tokenErrorResponse.errorDescription = "App is blocked (retry)" + val oauthException = OAuthFailedException(tokenErrorResponse, 403) + setupExchangeCodeMock(oauthException) + + spyViewModel.selectedServer.value = "https://test.salesforce.com" + spyViewModel.doCodeExchange("test_auth_code", mockOnError, mockOnSuccess) + + verify { mockOnError("Token Request Error", any(), oauthException) } + verify(exactly = 0) { mockOnSuccess(any()) } + } + + @Test + fun doCodeExchange_whenExchangeCodeThrowsIOException_callsOnAuthFlowError() = runBlocking { + val mockOnError: (String, String?, Throwable?) -> Unit = mockk(relaxed = true) + val mockOnSuccess: (UserAccount) -> Unit = mockk(relaxed = true) + val spyViewModel = spyk(viewModel) + + val ioException = IOException("Network error") + setupExchangeCodeMock(ioException) + + spyViewModel.selectedServer.value = "https://test.salesforce.com" + spyViewModel.doCodeExchange("test_auth_code", mockOnError, mockOnSuccess) + + verify { mockOnError("Token Request Error", "Network error", ioException) } + verify(exactly = 0) { mockOnSuccess(any()) } + } + + @Test + fun doCodeExchange_whenExchangeCodeThrowsOAuthFailed_neverCallsOnAuthFlowComplete() = runBlocking { + val mockOnError: (String, String?, Throwable?) -> Unit = mockk(relaxed = true) + val mockOnSuccess: (UserAccount) -> Unit = mockk(relaxed = true) + val spyViewModel = spyk(viewModel) + + val tokenErrorResponse = mockk(relaxed = true) + tokenErrorResponse.error = "invalid_grant" + tokenErrorResponse.errorDescription = "Expired authorization code" + val oauthException = OAuthFailedException(tokenErrorResponse, 400) + setupExchangeCodeMock(oauthException) + + coEvery { + spyViewModel.onAuthFlowComplete(any(), any(), any(), any(), any()) + } just runs + + spyViewModel.selectedServer.value = "https://test.salesforce.com" + spyViewModel.doCodeExchange("test_auth_code", mockOnError, mockOnSuccess) + + verify { mockOnError("Token Request Error", any(), oauthException) } + verify(exactly = 0) { mockOnSuccess(any()) } + coVerify(exactly = 0) { + spyViewModel.onAuthFlowComplete(any(), any(), any(), any(), any()) + } + } + + // endregion + // region showBiometricAuthenticationButton Tests @Test diff --git a/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/ui/LoginActivityTest.kt b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/ui/LoginActivityTest.kt index 1e96ba1f7d..5569bc3146 100644 --- a/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/ui/LoginActivityTest.kt +++ b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/ui/LoginActivityTest.kt @@ -129,7 +129,7 @@ class LoginActivityTest { val activity = mockk(relaxed = true) every { activity.viewModel } returns viewModel - val adminResult = activity.AdminCustomTabActivityResult() + val adminResult = LoginActivity.AdminCustomTabActivityResult() adminResult.onActivityResult(ActivityResult(RESULT_CANCELED, Intent())) // Contrast with CustomTabActivityResult which calls these on cancel; the admin