Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions libs/SalesforceSDK/res/values/sf__strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
<string name="sf__managed_app_error">Authentication only allowed from managed device.</string>
<string name="sf__jwt_authentication_error">JWT authentication error. Please try again.</string>
<string name="sf__lightning_url_code_exchange_error">Lightning URLs are not supported for OAuth code exchange. Use your My Domain URL instead.</string>
<string name="sf__app_blocked_error">This app has been blocked. Contact your administrator for assistance.</string>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The server returns user_blocked error when the app failed app attestation.
The error returned will probably change (soon) to reflect that - but our message here could already be more descriptive.


<!-- SSL errors -->
<string name="sf__ssl_error">SSL error: %s.</string>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@ import com.salesforce.androidsdk.R.string.cannot_use_another_apps_login_qr_code
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
import com.salesforce.androidsdk.R.string.sf__app_blocked_error
import com.salesforce.androidsdk.R.string.sf__lightning_url_code_exchange_error
import com.salesforce.androidsdk.R.string.sf__login_with_biometric
import com.salesforce.androidsdk.R.string.sf__screen_lock_error
Expand All @@ -125,6 +126,7 @@ 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.OAuthFailedException
import com.salesforce.androidsdk.auth.OAuth2.TokenEndpointResponse
import com.salesforce.androidsdk.auth.OAuth2.swapJWTForTokens
Expand Down Expand Up @@ -584,6 +586,8 @@ open class LoginActivity : FragmentActivity() {
)

viewModel.clearCookies()
val isClientBlocked = e is OAuthFailedException

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's also recognize CLIENT_BLOCKED_RETRY_ERROR.
For now we will just bubble it up to the app/user.
Later, we might add retry-logic (in the case of refresh flow).

&& e.tokenErrorResponse.error == CLIENT_BLOCKED_ERROR
val isLightningTokenEndpointFailure = e is OAuthFailedException
&& e.tokenErrorResponse.error == "unsupported_grant_type"
&& viewModel.selectedServer.value?.contains(".lightning.") == true
Expand All @@ -592,11 +596,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 toastMessage = when {
isClientBlocked -> getString(sf__app_blocked_error)
isLightningTokenEndpointFailure -> getString(sf__lightning_url_code_exchange_error)
else -> "$error : $errorDesc"
}
makeText(this, toastMessage, LENGTH_LONG).show()
viewModel.reloadWebView()
}
}
Expand Down
5 changes: 5 additions & 0 deletions libs/test/SalesforceSDKTest/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,11 @@



<!-- Test-only activity for LoginActivityAuthErrorTest -->
<activity android:name="com.salesforce.androidsdk.auth.TestLoginActivity"
android:exported="false"
android:theme="@style/SalesforceSDK" />

<!-- Launcher screen -->
<activity android:name="com.salesforce.androidsdk.MainActivity"
android:label="@string/app_name"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,226 @@
/*
* Copyright (c) 2025-present, salesforce.com, inc.
* All rights reserved.
* Redistribution and use of this software in source and binary forms, with or
* without modification, are permitted provided that the following conditions
* are met:
* - Redistributions of source code must retain the above copyright notice, this
* list of conditions and the following disclaimer.
* - Redistributions in binary form must reproduce the above copyright notice,
* this list of conditions and the following disclaimer in the documentation
* and/or other materials provided with the distribution.
* - Neither the name of salesforce.com, inc. nor the names of its contributors
* may be used to endorse or promote products derived from this software without
* specific prior written permission of salesforce.com, inc.
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
* AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
* IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
* ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE
* LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
* CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
* SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
* INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
* CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
* ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
* POSSIBILITY OF SUCH DAMAGE.
*/
package com.salesforce.androidsdk.auth

import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import androidx.test.core.app.ActivityScenario.launch
import androidx.test.core.app.ApplicationProvider.getApplicationContext
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.salesforce.androidsdk.accounts.UserAccount
import com.salesforce.androidsdk.auth.OAuth2.CLIENT_BLOCKED_ERROR
import com.salesforce.androidsdk.auth.OAuth2.OAuthFailedException
import com.salesforce.androidsdk.ui.LoginActivity
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkStatic
import io.mockk.unmockkAll
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit

/**
* Test subclass that exposes the protected onAuthFlowError for testing.
*/
class TestLoginActivity : LoginActivity() {
public override fun onAuthFlowError(error: String, errorDesc: String?, e: Throwable?) {
super.onAuthFlowError(error, errorDesc, e)
}

override fun onAuthFlowSuccess(userAccount: UserAccount) {
// No-op for tests
}
}

@RunWith(AndroidJUnit4::class)
class LoginActivityAuthErrorTest {

private companion object {
const val AUTHENTICATION_FAILED_INTENT = "com.salesforce.auth.intent.AUTHENTICATION_ERROR"
const val HTTP_ERROR_RESPONSE_CODE_INTENT = "com.salesforce.auth.intent.HTTP_RESPONSE_CODE"
const val RESPONSE_ERROR_INTENT = "com.salesforce.auth.intent.RESPONSE_ERROR"
const val RESPONSE_ERROR_DESCRIPTION_INTENT = "com.salesforce.auth.intent.RESPONSE_ERROR_DESCRIPTION"
}

@Before
fun setup() {
OAuth2.TIMESTAMP_FORMAT
mockkStatic(OAuth2::class)
}

@After
fun teardown() {
unmockkAll()
}

@Test
fun onAuthFlowError_givenClientBlocked_broadcastsWithCorrectExtras() {
val tokenErrorResponse = mockk<OAuth2.TokenErrorResponse>(relaxed = true)
tokenErrorResponse.error = CLIENT_BLOCKED_ERROR
tokenErrorResponse.errorDescription = "App is blocked by admin"
val oauthException = OAuthFailedException(tokenErrorResponse, 403)

every {
OAuth2.exchangeCode(any(), any(), any(), any(), any(), any())
} throws oauthException

val latch = CountDownLatch(1)
var receivedIntent: Intent? = null

val receiver = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
receivedIntent = intent
latch.countDown()
}
}

val context: Context = getApplicationContext()
context.registerReceiver(
receiver,
IntentFilter(AUTHENTICATION_FAILED_INTENT),
Context.RECEIVER_EXPORTED
)

try {
launch<TestLoginActivity>(
Intent(context, TestLoginActivity::class.java)
).use { activityScenario ->
activityScenario.onActivity { activity ->
activity.viewModel.onWebServerFlowComplete(
"test_code",
{ error, errorDesc, e -> activity.onAuthFlowError(error, errorDesc, e) },
{ },
)
}

assertTrue("Broadcast should be received within 5 seconds", latch.await(5, TimeUnit.SECONDS))
assertNotNull(receivedIntent)
assertEquals(403, receivedIntent!!.getIntExtra(HTTP_ERROR_RESPONSE_CODE_INTENT, 0))
assertEquals(CLIENT_BLOCKED_ERROR, receivedIntent!!.getStringExtra(RESPONSE_ERROR_INTENT))
assertEquals("App is blocked by admin", receivedIntent!!.getStringExtra(RESPONSE_ERROR_DESCRIPTION_INTENT))
}
} finally {
context.unregisterReceiver(receiver)
}
}

@Test
fun onAuthFlowError_givenClientBlocked_showsAppBlockedToast() {
val tokenErrorResponse = mockk<OAuth2.TokenErrorResponse>(relaxed = true)
tokenErrorResponse.error = CLIENT_BLOCKED_ERROR
tokenErrorResponse.errorDescription = "App is blocked"
val oauthException = OAuthFailedException(tokenErrorResponse, 403)

every {
OAuth2.exchangeCode(any(), any(), any(), any(), any(), any())
} throws oauthException

launch<TestLoginActivity>(
Intent(getApplicationContext(), TestLoginActivity::class.java)
).use { activityScenario ->
activityScenario.onActivity { activity ->
activity.viewModel.onWebServerFlowComplete(
"test_code",
{ error, errorDesc, e -> activity.onAuthFlowError(error, errorDesc, e) },
{ },
)
}

// Allow time for the coroutine + runOnUiThread to complete
Thread.sleep(500)

activityScenario.onActivity { activity ->
val expectedMessage = activity.getString(
com.salesforce.androidsdk.R.string.sf__app_blocked_error
)
assertEquals(
"This app has been blocked. Contact your administrator for assistance.",
expectedMessage
)
}
}
}

@Test
fun onAuthFlowError_givenGenericOAuthError_broadcastsWithCorrectExtras() {
val tokenErrorResponse = mockk<OAuth2.TokenErrorResponse>(relaxed = true)
tokenErrorResponse.error = "invalid_grant"
tokenErrorResponse.errorDescription = "Expired authorization code"
val oauthException = OAuthFailedException(tokenErrorResponse, 400)

every {
OAuth2.exchangeCode(any(), any(), any(), any(), any(), any())
} throws oauthException

val latch = CountDownLatch(1)
var receivedIntent: Intent? = null

val receiver = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
receivedIntent = intent
latch.countDown()
}
}

val context: Context = getApplicationContext()
context.registerReceiver(
receiver,
IntentFilter(AUTHENTICATION_FAILED_INTENT),
Context.RECEIVER_EXPORTED
)

try {
launch<TestLoginActivity>(
Intent(context, TestLoginActivity::class.java)
).use { activityScenario ->
activityScenario.onActivity { activity ->
activity.viewModel.onWebServerFlowComplete(
"test_code",
{ error, errorDesc, e -> activity.onAuthFlowError(error, errorDesc, e) },
{ },
)
}

assertTrue("Broadcast should be received within 5 seconds", latch.await(5, TimeUnit.SECONDS))
assertNotNull(receivedIntent)
assertEquals(400, receivedIntent!!.getIntExtra(HTTP_ERROR_RESPONSE_CODE_INTENT, 0))
assertEquals("invalid_grant", receivedIntent!!.getStringExtra(RESPONSE_ERROR_INTENT))
assertEquals("Expired authorization code", receivedIntent!!.getStringExtra(RESPONSE_ERROR_DESCRIPTION_INTENT))
}
} finally {
context.unregisterReceiver(receiver)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -787,6 +787,107 @@ class LoginViewModelMockTest {

// endregion

// region doCodeExchange Error Path Tests

@Test
fun doCodeExchange_whenExchangeCodeThrowsClientBlocked_callsOnAuthFlowError() = runBlocking {
val testCode = "test_auth_code"
val mockOnError: (String, String?, Throwable?) -> Unit = mockk(relaxed = true)
val mockOnSuccess: (UserAccount) -> Unit = mockk(relaxed = true)

val spyViewModel = spyk(viewModel)

// Force OAuth2 class initialization before mocking
OAuth2.TIMESTAMP_FORMAT
mockkStatic(OAuth2::class)

val tokenErrorResponse = mockk<OAuth2.TokenErrorResponse>(relaxed = true)
tokenErrorResponse.error = OAuth2.CLIENT_BLOCKED_ERROR
tokenErrorResponse.errorDescription = "App is blocked"
val oauthException = OAuth2.OAuthFailedException(tokenErrorResponse, 403)

every {
OAuth2.exchangeCode(any(), any(), any(), any(), any(), any())
} throws oauthException

spyViewModel.selectedServer.value = "https://test.salesforce.com"
Thread.sleep(100)

spyViewModel.doCodeExchange(testCode, mockOnError, mockOnSuccess)
Thread.sleep(200)

verify {
mockOnError("Token Request Error", any(), oauthException)
}
}

@Test
fun doCodeExchange_whenExchangeCodeThrowsIOException_callsOnAuthFlowError() = runBlocking {
val testCode = "test_auth_code"
val mockOnError: (String, String?, Throwable?) -> Unit = mockk(relaxed = true)
val mockOnSuccess: (UserAccount) -> Unit = mockk(relaxed = true)

val spyViewModel = spyk(viewModel)

// Force OAuth2 class initialization before mocking
OAuth2.TIMESTAMP_FORMAT
mockkStatic(OAuth2::class)

val ioException = java.io.IOException("Network error")

every {
OAuth2.exchangeCode(any(), any(), any(), any(), any(), any())
} throws ioException

spyViewModel.selectedServer.value = "https://test.salesforce.com"
Thread.sleep(100)

spyViewModel.doCodeExchange(testCode, mockOnError, mockOnSuccess)
Thread.sleep(200)

verify {
mockOnError("Token Request Error", "Network error", ioException)
}
}

@Test
fun doCodeExchange_whenExchangeCodeThrowsOAuthFailed_neverCallsOnAuthFlowComplete() = runBlocking {
val testCode = "test_auth_code"
val mockOnError: (String, String?, Throwable?) -> Unit = mockk(relaxed = true)
val mockOnSuccess: (UserAccount) -> Unit = mockk(relaxed = true)

val spyViewModel = spyk(viewModel)

// Force OAuth2 class initialization before mocking
OAuth2.TIMESTAMP_FORMAT
mockkStatic(OAuth2::class)

val tokenErrorResponse = mockk<OAuth2.TokenErrorResponse>(relaxed = true)
tokenErrorResponse.error = "invalid_grant"
tokenErrorResponse.errorDescription = "Expired authorization code"
val oauthException = OAuth2.OAuthFailedException(tokenErrorResponse, 400)

every {
OAuth2.exchangeCode(any(), any(), any(), any(), any(), any())
} throws oauthException

coEvery {
spyViewModel.onAuthFlowComplete(any(), any(), any(), any(), any())
} just runs

spyViewModel.selectedServer.value = "https://test.salesforce.com"
Thread.sleep(100)

spyViewModel.doCodeExchange(testCode, mockOnError, mockOnSuccess)
Thread.sleep(200)

coVerify(exactly = 0) {
spyViewModel.onAuthFlowComplete(any(), any(), any(), any(), any())
}
}

// endregion

// region showBiometricAuthenticationButton Tests

@Test
Expand Down
Loading