Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
4a865ee
@W-22701996: [Android] Improve error handling at token refresh (Surfa…
JohnsonEricAtSalesforce Jun 2, 2026
aea4ef0
@W-22701996: [Android] Improve error handling at token refresh (Code …
JohnsonEricAtSalesforce Jun 3, 2026
2e3a32a
@W-22701996: [Android] Improve error handling at token refresh (Code …
JohnsonEricAtSalesforce Jun 3, 2026
fe0da0b
@W-22701996: [Android] Improve error handling at token refresh (Code …
JohnsonEricAtSalesforce Jun 3, 2026
83a9985
@W-22701996: [Android] Improve error handling at token refresh (Code …
JohnsonEricAtSalesforce Jun 3, 2026
596e8c5
@W-22701996: [Android] Improve error handling at token refresh (Code …
JohnsonEricAtSalesforce Jun 3, 2026
90b1c4b
@W-22701996: [Android] Improve error handling at token refresh (Code …
JohnsonEricAtSalesforce Jun 3, 2026
f989e9e
@W-22701996: [Android] Improve error handling at token refresh (Code …
JohnsonEricAtSalesforce Jun 3, 2026
9a1a066
@W-22701996: [Android] Improve error handling at token refresh (Code …
JohnsonEricAtSalesforce Jun 3, 2026
63fe85f
@W-22701996: [Android] Improve error handling at token refresh (Code …
JohnsonEricAtSalesforce Jun 3, 2026
c545ce1
@W-22701996: [Android] Improve error handling at token refresh (Code …
JohnsonEricAtSalesforce Jun 3, 2026
6d48b26
@W-22701996: [Android] Improve error handling at token refresh (Code …
JohnsonEricAtSalesforce Jun 3, 2026
939347c
@W-22701996: [Android] Improve error handling at token refresh (Code …
JohnsonEricAtSalesforce Jun 3, 2026
cfb1711
@W-22701996: [Android] Improve error handling at token refresh (Code …
JohnsonEricAtSalesforce Jun 4, 2026
a1e5927
@W-22701996: [Android] Improve error handling at token refresh (Code …
JohnsonEricAtSalesforce Jun 4, 2026
f575899
@W-22701996: [Android] Improve error handling at token refresh (Code …
JohnsonEricAtSalesforce Jun 4, 2026
668c9c6
@W-22701996: [Android] Improve error handling at token refresh (Add A…
JohnsonEricAtSalesforce Jun 4, 2026
8806aba
@W-22701996: [Android] Improve error handling at token refresh (Code …
JohnsonEricAtSalesforce Jun 4, 2026
ff438ba
@W-22701996: [Android] Improve error handling at token refresh (Code …
JohnsonEricAtSalesforce Jun 4, 2026
abeff0d
@W-22701996: [Android] Improve error handling at token refresh (Code …
JohnsonEricAtSalesforce Jun 4, 2026
cb21c0a
@W-22701996: [Android] Improve error handling at token refresh (Code …
JohnsonEricAtSalesforce Jun 4, 2026
769a3a0
@W-22701996: [Android] Improve error handling at token refresh (Code …
JohnsonEricAtSalesforce Jun 4, 2026
9dee21d
@W-22701996: [Android] Improve error handling at token refresh (Code …
JohnsonEricAtSalesforce Jun 4, 2026
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
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@
*/
package com.salesforce.androidsdk.auth;

import static com.salesforce.androidsdk.auth.OAuth2.USER_BLOCKED_RETRY_ERROR;

import android.accounts.AbstractAccountAuthenticator;
import android.accounts.Account;
import android.accounts.AccountAuthenticatorResponse;
Expand Down Expand Up @@ -147,13 +149,14 @@ public Bundle getAuthToken(AccountAuthenticatorResponse response, Account accoun

return resBundle;
} catch (OAuthFailedException ofe) {
if (ofe.isRefreshTokenInvalid()) {
SalesforceSDKLogger.i(TAG, "Invalid Refresh Token: (Error: " +
ofe.response.error + ", Status Code: " + ofe.httpStatusCode + ")", ofe);
SalesforceSDKLogger.i(TAG, "Token endpoint error: (Error: " + ofe.response.error + ", Status Code: " + ofe.httpStatusCode + ")", ofe);

// Terminal errors (except retriable attestation) redirect to login.
if (!USER_BLOCKED_RETRY_ERROR.equals(ofe.response.error) && ofe.isRefreshTokenInvalid()) {

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Requirement #4: For the AuthenticatorService path, user_blocked_retry must NOT redirect to login. Since isRefreshTokenInvalid() only checks HTTP status codes (400/401/403) and user_blocked_retry returns 400, we check the error type first. The combined condition ensures retriable errors return an error bundle while terminal errors still redirect to login.

return makeAuthIntentBundle(response, options);
}

Bundle resBundle = new Bundle();
final Bundle resBundle = new Bundle();
resBundle.putString(AccountManager.KEY_ERROR_CODE, ofe.response.error);
resBundle.putString(AccountManager.KEY_ERROR_MESSAGE, ofe.response.errorDescription);
return resBundle;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,12 @@ public class OAuth2 {
public static final String LOGIN_HINT = "login_hint";
private static final String REFRESH_TOKEN = "refresh_token"; // Grant Type Values

/** Token endpoint error: device/app permanently blocked by attestation. Triggers logout. */
public static final String USER_BLOCKED_ERROR = "user_blocked";

/** Token endpoint error: attestation could not be verified but may succeed on retry. Does not trigger logout. */
public static final String USER_BLOCKED_RETRY_ERROR = "user_blocked_retry";

/**
* OAuth 2.0 authorization endpoint request body parameter names:
* Salesforce App Attestation External Client App Attestation
Expand Down Expand Up @@ -236,6 +242,7 @@ public enum LogoutReason {
UNEXPECTED, // Unexpected error or crash
UNEXPECTED_RESPONSE, // Unexpected response from server
UNKNOWN, // Unknown

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Requirement #3: USER_BLOCKED provides a distinct logout reason for attestation failures, differentiating from REFRESH_TOKEN_EXPIRED. Available to SalesforceSDKManager.logout() callers and analytics/telemetry.

USER_BLOCKED, // Device/app blocked by server (e.g. failed attestation)
USER_LOGOUT, // User initiated logout
REFRESH_TOKEN_ROTATED; // Refresh token rotated

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,12 @@
*/
package com.salesforce.androidsdk.rest;

import static com.salesforce.androidsdk.auth.OAuth2.LogoutReason.REFRESH_TOKEN_EXPIRED;
import static com.salesforce.androidsdk.auth.OAuth2.LogoutReason.USER_BLOCKED;
import static com.salesforce.androidsdk.auth.OAuth2.USER_BLOCKED_ERROR;
import static com.salesforce.androidsdk.auth.OAuth2.USER_BLOCKED_RETRY_ERROR;
import static com.salesforce.androidsdk.auth.OAuth2.refreshAuthToken;

import android.accounts.Account;
import android.accounts.AccountManager;
import android.accounts.NetworkErrorException;
Expand All @@ -42,7 +48,10 @@
import com.salesforce.androidsdk.app.SalesforceSDKManager;
import com.salesforce.androidsdk.auth.AuthenticatorService;
import com.salesforce.androidsdk.auth.HttpAccess;
import com.salesforce.androidsdk.auth.OAuth2;
import com.salesforce.androidsdk.auth.OAuth2.LogoutReason;
import com.salesforce.androidsdk.auth.OAuth2.OAuthFailedException;
import com.salesforce.androidsdk.auth.OAuth2.TokenEndpointResponse;
import com.salesforce.androidsdk.auth.OAuth2.TokenErrorResponse;
import com.salesforce.androidsdk.rest.RestClient.ClientInfo;
import com.salesforce.androidsdk.util.SalesforceSDKLogger;

Expand All @@ -60,6 +69,11 @@ public class ClientManager {
public static final String ACCESS_TOKEN_REVOKE_INTENT = "access_token_revoked";
public static final String ACCESS_TOKEN_REFRESH_INTENT = "access_token_refeshed";
public static final String INSTANCE_URL_UPDATE_INTENT = "instance_url_updated";
/** Intent extra: the {@code error} value from the token endpoint response (e.g. "user_blocked", "invalid_grant"). */
public static final String EXTRA_TOKEN_ERROR = "token_error";

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Requirement #1 & #2: These constants surface the token endpoint's error and error_description fields in the broadcast intent extras, allowing apps to distinguish between failure modes (e.g. show "app blocked" vs "session expired").


/** Intent extra: the {@code error_description} value from the token endpoint response. */
public static final String EXTRA_TOKEN_ERROR_DESCRIPTION = "token_error_description";
private static final String TAG = "ClientManager";

private final AccountManager accountManager;
Expand Down Expand Up @@ -397,17 +411,18 @@ public String getNewAuthToken() {
String newAuthToken = null;
String newInstanceUrl = null;
boolean shouldUpdateCache = false;
Account[] accounts = null;
Account matchingAccount = null;

try {
// Only check for matching account inside synchronized thread that
// is actually getting the new auth token.
UserAccountManager userAccountManager = SalesforceSDKManager.getInstance().getUserAccountManager();
Account[] accounts = clientManager.getAccounts();
Account matchingAccount = null;
final UserAccountManager userAccountManager = SalesforceSDKManager.getInstance().getUserAccountManager();
accounts = clientManager.getAccounts();

if (refreshToken != null) {
for (Account account : accounts) {
UserAccount user = userAccountManager.buildUserAccount(account);
final UserAccount user = userAccountManager.buildUserAccount(account);
if (user != null && refreshToken.equals(user.getRefreshToken())) {
matchingAccount = account;
break;
Expand All @@ -423,41 +438,66 @@ public String getNewAuthToken() {
// We found a matching account, so we'll attempt a refresh and should update the cache.
shouldUpdateCache = true;

// Invalidate current auth token.
clientManager.invalidateToken(lastNewAuthToken);
/*
* Invalidate current auth token. After a prior
* user_blocked_retry the cached token is null because
* that path clears it without logging out.
* AccountManager.invalidateAuthToken is a no-op for
* null, but guarding here avoids a wasteful call whose
* frequency increases with retriable attestation
* errors.
*/
if (lastNewAuthToken != null) {
clientManager.invalidateToken(lastNewAuthToken);
}
final UserAccount userAccount = refreshStaleToken(matchingAccount);

// NB: userAccount will be null if refresh token is no longer valid
newAuthToken = userAccount != null ? userAccount.getAuthToken() : null;
newInstanceUrl = userAccount != null ? userAccount.getInstanceServer() : null;
newAuthToken = userAccount.getAuthToken();
newInstanceUrl = userAccount.getInstanceServer();

Intent broadcastIntent;
if (newAuthToken == null) {
if (clientManager.revokedTokenShouldLogout) {
if (newInstanceUrl != null && !newInstanceUrl.equalsIgnoreCase(lastNewInstanceUrl)) {

// Broadcasts an intent that the instance server has changed (implicitly token refreshed too).
broadcastIntent = new Intent(INSTANCE_URL_UPDATE_INTENT);
} else {

// Check if a looper exists before trying to prepare another one.
// Broadcasts an intent that the access token has been refreshed.
broadcastIntent = new Intent(ACCESS_TOKEN_REFRESH_INTENT);
EventBuilderHelper.createAndStoreEvent("tokenRefresh", null, TAG, null);
}
broadcastIntent.setPackage(SalesforceSDKManager.getInstance().getAppContext().getPackageName());
SalesforceSDKManager.getInstance().getAppContext().sendBroadcast(broadcastIntent);
} catch (OAuthFailedException ofe) {
final TokenErrorResponse tokenError = ofe.getTokenErrorResponse();
final String errorType = tokenError != null ? tokenError.error : null;
final String errorDesc = tokenError != null ? tokenError.errorDescription : null;

if (!USER_BLOCKED_RETRY_ERROR.equals(errorType)) {

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Requirement #4: user_blocked_retry is retriable — the server couldn't verify attestation but the refresh token is still valid. We skip logout here so the app can re-attest and retry. All other errors (including user_blocked) are terminal and trigger logout.

Requirement #3: user_blocked uses LogoutReason.USER_BLOCKED instead of REFRESH_TOKEN_EXPIRED so apps/analytics can distinguish "blocked by attestation" from "token expired".

// Terminal error (user_blocked, invalid_grant, etc.) — logout.
if (clientManager.revokedTokenShouldLogout) {
if (Looper.myLooper() == null) {
Looper.prepare();
}
boolean showLoginPage = accounts.length == 1;
final boolean showLoginPage = accounts.length == 1;
final LogoutReason reason = USER_BLOCKED_ERROR.equals(errorType)
? USER_BLOCKED
: REFRESH_TOKEN_EXPIRED;
// Note: As of writing (2024) this call will never succeed because revoke API is an
// authenticated endpoint. However, there is no harm in attempting and the debug logs
// produced may help developers better understand the state of their app.
SalesforceSDKManager.getInstance()
.logout(matchingAccount, null, showLoginPage, OAuth2.LogoutReason.REFRESH_TOKEN_EXPIRED);
.logout(matchingAccount, null, showLoginPage, reason);
}
}

// Broadcasts an intent that the refresh token has been revoked.
broadcastIntent = new Intent(ACCESS_TOKEN_REVOKE_INTENT);
} else if (newInstanceUrl != null && !newInstanceUrl.equalsIgnoreCase(lastNewInstanceUrl)) {

// Broadcasts an intent that the instance server has changed (implicitly token refreshed too).
broadcastIntent = new Intent(INSTANCE_URL_UPDATE_INTENT);
} else {

// Broadcasts an intent that the access token has been refreshed.
broadcastIntent = new Intent(ACCESS_TOKEN_REFRESH_INTENT);
EventBuilderHelper.createAndStoreEvent("tokenRefresh", null, TAG, null);
// Broadcast revoke intent with error details for all OAuth failures.
final Intent broadcastIntent = new Intent(ACCESS_TOKEN_REVOKE_INTENT);
if (errorType != null) {
broadcastIntent.putExtra(EXTRA_TOKEN_ERROR, errorType);
}
if (errorDesc != null) {
broadcastIntent.putExtra(EXTRA_TOKEN_ERROR_DESCRIPTION, errorDesc);
}
broadcastIntent.setPackage(SalesforceSDKManager.getInstance().getAppContext().getPackageName());
SalesforceSDKManager.getInstance().getAppContext().sendBroadcast(broadcastIntent);
Expand Down Expand Up @@ -490,11 +530,11 @@ public long getLastRefreshTime() {
@Override
public String getInstanceUrl() { return lastNewInstanceUrl; }

private UserAccount refreshStaleToken(Account account) throws NetworkErrorException {
private UserAccount refreshStaleToken(Account account) throws NetworkErrorException, OAuthFailedException {
UserAccount originalUserAccount = UserAccountManager.getInstance().buildUserAccount(account);
final Map<String,String> addlParamsMap = originalUserAccount.getAdditionalOauthValues();
try {
final OAuth2.TokenEndpointResponse tr = OAuth2.refreshAuthToken(HttpAccess.DEFAULT,
final TokenEndpointResponse tr = refreshAuthToken(HttpAccess.DEFAULT,
new URI(originalUserAccount.getLoginServer()), originalUserAccount.getClientIdForRefresh(), refreshToken, addlParamsMap);

UserAccount updatedUserAccount = UserAccountBuilder.getInstance()
Expand All @@ -514,13 +554,9 @@ private UserAccount refreshStaleToken(Account account) throws NetworkErrorExcept
}

return updatedUserAccount;
} catch (OAuth2.OAuthFailedException ofe) {
if (ofe.isRefreshTokenInvalid()) {
SalesforceSDKLogger.i(TAG, "Invalid Refresh Token: (Error: " +
ofe.getTokenErrorResponse().error + ", Status Code: " +
ofe.getHttpStatusCode() + ")", ofe);
}
return null;
} catch (OAuthFailedException ofe) {

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Requirement #1 (enabler): Previously this method caught OAuthFailedException, logged, and returned null — discarding all error context. Now it rethrows so that getNewAuthToken() can inspect the specific error type and branch accordingly.

SalesforceSDKLogger.i(TAG, "Token endpoint error: (Error: " + ofe.getTokenErrorResponse().error + ", Status Code: " + ofe.getHttpStatusCode() + ")", ofe);
throw ofe;
} catch (Exception e) {
SalesforceSDKLogger.e(TAG, "Exception thrown while getting new auth token", e);
throw new NetworkErrorException(e);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
package com.salesforce.androidsdk.auth

import android.accounts.AbstractAccountAuthenticator
import android.accounts.Account
import android.accounts.AccountManager
import android.content.Context
import androidx.test.filters.SmallTest
import androidx.test.platform.app.InstrumentationRegistry
import com.salesforce.androidsdk.accounts.UserAccount
import com.salesforce.androidsdk.accounts.UserAccountManager
import com.salesforce.androidsdk.app.SalesforceSDKManager
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkObject
import io.mockk.mockkStatic
import io.mockk.unmockkAll
import okhttp3.Call
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Response
import okhttp3.ResponseBody.Companion.toResponseBody
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Before
import org.junit.Test

@SmallTest
class AuthenticatorServiceTest {

private lateinit var authenticator: AbstractAccountAuthenticator
private lateinit var mockUserAccountManager: UserAccountManager
private lateinit var mockAppContext: Context
private lateinit var mockAccount: Account
private lateinit var mockUser: UserAccount

@Before
fun setUp() {
val targetContext = InstrumentationRegistry.getInstrumentation().targetContext
mockAppContext = mockk(relaxed = true) {
every { packageName } returns "packageName"
every { filesDir } returns targetContext.filesDir
every { getSharedPreferences(any(), any()) } answers {
targetContext.getSharedPreferences(firstArg(), Context.MODE_PRIVATE)
}
}

mockUserAccountManager = mockk(relaxed = true)

mockkObject(SalesforceSDKManager)
val mockSDKManager = mockk<SalesforceSDKManager> {
every { userAccountManager } returns mockUserAccountManager
every { appContext } returns mockAppContext
every { deviceId } returns "test-device-id"
every { additionalOauthKeys } returns emptyList()
every { useHybridAuthentication } returns true
every { appAttestationClient } returns null
@Suppress("UNCHECKED_CAST")
every { loginActivityClass } returns Class.forName("com.salesforce.androidsdk.ui.LoginActivity") as Class<out android.app.Activity>
}
every { SalesforceSDKManager.getInstance() } returns mockSDKManager

mockkStatic(UserAccountManager::class)
every { UserAccountManager.getInstance() } returns mockUserAccountManager

mockkObject(HttpAccess.DEFAULT)

mockAccount = mockk(relaxed = true)
mockUser = mockk<UserAccount>(relaxed = true) {
every { loginServer } returns "https://login.salesforce.com"
every { refreshToken } returns "refresh-token"
every { clientIdForRefresh } returns "client-id"
}
every { mockUserAccountManager.buildUserAccount(mockAccount) } returns mockUser

// Instantiate the private Authenticator inner class via reflection.
val authenticatorClass = Class.forName("com.salesforce.androidsdk.auth.AuthenticatorService\$Authenticator")
val constructor = authenticatorClass.getDeclaredConstructor(Context::class.java)
constructor.isAccessible = true
authenticator = constructor.newInstance(targetContext) as AbstractAccountAuthenticator
}

@After
fun tearDown() {
unmockkAll()
}

private fun setupTokenErrorResponse(error: String, errorDescription: String) {
val errorBody = """
{"error": "$error", "error_description": "$errorDescription"}
""".trimIndent().toResponseBody("application/json; charset=utf-8".toMediaType())
every { HttpAccess.DEFAULT.okHttpClient } returns mockk<OkHttpClient> {
every { newCall(any()) } returns mockk<Call> {
every { execute() } returns mockk<Response>(relaxed = true) {
every { isSuccessful } returns false
every { code } returns 400
every { body } returns errorBody
}
}
}
}

@Test
fun testGetAuthToken_userBlockedRetry_returnsErrorBundle() {
setupTokenErrorResponse("user_blocked_retry", "Attestation verification pending")

val result = authenticator.getAuthToken(null, mockAccount, "authTokenType", null)

assertEquals("user_blocked_retry", result.getString(AccountManager.KEY_ERROR_CODE))
assertEquals("Attestation verification pending", result.getString(AccountManager.KEY_ERROR_MESSAGE))
assertNull(result.getParcelable<android.content.Intent>(AccountManager.KEY_INTENT))
}

@Test
fun testGetAuthToken_userBlocked_returnsLoginIntent() {
setupTokenErrorResponse("user_blocked", "Device failed integrity check")

val result = authenticator.getAuthToken(null, mockAccount, "authTokenType", null)

assertNotNull(result.getParcelable<android.content.Intent>(AccountManager.KEY_INTENT))
assertNull(result.getString(AccountManager.KEY_ERROR_CODE))
}

@Test
fun testGetAuthToken_invalidGrant_returnsLoginIntent() {
setupTokenErrorResponse("invalid_grant", "expired authorization code")

val result = authenticator.getAuthToken(null, mockAccount, "authTokenType", null)

assertNotNull(result.getParcelable<android.content.Intent>(AccountManager.KEY_INTENT))
assertNull(result.getString(AccountManager.KEY_ERROR_CODE))
}
}
Loading
Loading