diff --git a/libs/SalesforceSDK/src/com/salesforce/androidsdk/auth/AuthenticatorService.java b/libs/SalesforceSDK/src/com/salesforce/androidsdk/auth/AuthenticatorService.java index f60c26cfe8..ae29827883 100644 --- a/libs/SalesforceSDK/src/com/salesforce/androidsdk/auth/AuthenticatorService.java +++ b/libs/SalesforceSDK/src/com/salesforce/androidsdk/auth/AuthenticatorService.java @@ -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; @@ -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()) { 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; diff --git a/libs/SalesforceSDK/src/com/salesforce/androidsdk/auth/OAuth2.java b/libs/SalesforceSDK/src/com/salesforce/androidsdk/auth/OAuth2.java index a9d09892bc..15c69062dd 100644 --- a/libs/SalesforceSDK/src/com/salesforce/androidsdk/auth/OAuth2.java +++ b/libs/SalesforceSDK/src/com/salesforce/androidsdk/auth/OAuth2.java @@ -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 @@ -236,6 +242,7 @@ public enum LogoutReason { UNEXPECTED, // Unexpected error or crash UNEXPECTED_RESPONSE, // Unexpected response from server UNKNOWN, // Unknown + USER_BLOCKED, // Device/app blocked by server (e.g. failed attestation) USER_LOGOUT, // User initiated logout REFRESH_TOKEN_ROTATED; // Refresh token rotated diff --git a/libs/SalesforceSDK/src/com/salesforce/androidsdk/rest/ClientManager.java b/libs/SalesforceSDK/src/com/salesforce/androidsdk/rest/ClientManager.java index 84a23e7972..71b9e283ea 100644 --- a/libs/SalesforceSDK/src/com/salesforce/androidsdk/rest/ClientManager.java +++ b/libs/SalesforceSDK/src/com/salesforce/androidsdk/rest/ClientManager.java @@ -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; @@ -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; @@ -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"; + + /** 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; @@ -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; @@ -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)) { + // 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); @@ -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 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() @@ -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) { + 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); diff --git a/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/auth/AuthenticatorServiceTest.kt b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/auth/AuthenticatorServiceTest.kt new file mode 100644 index 0000000000..1415e2676a --- /dev/null +++ b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/auth/AuthenticatorServiceTest.kt @@ -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 { + 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 + } + every { SalesforceSDKManager.getInstance() } returns mockSDKManager + + mockkStatic(UserAccountManager::class) + every { UserAccountManager.getInstance() } returns mockUserAccountManager + + mockkObject(HttpAccess.DEFAULT) + + mockAccount = mockk(relaxed = true) + mockUser = mockk(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 { + every { newCall(any()) } returns mockk { + every { execute() } returns mockk(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(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(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(AccountManager.KEY_INTENT)) + assertNull(result.getString(AccountManager.KEY_ERROR_CODE)) + } +} diff --git a/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/rest/ClientManagerMockTest.kt b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/rest/ClientManagerMockTest.kt index 95984be653..13509b8f16 100644 --- a/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/rest/ClientManagerMockTest.kt +++ b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/rest/ClientManagerMockTest.kt @@ -13,6 +13,9 @@ import com.salesforce.androidsdk.analytics.EventBuilderHelper import com.salesforce.androidsdk.app.SalesforceSDKManager import com.salesforce.androidsdk.auth.HttpAccess import com.salesforce.androidsdk.auth.OAuth2 +import com.salesforce.androidsdk.rest.ClientManager.EXTRA_TOKEN_ERROR +import com.salesforce.androidsdk.rest.ClientManager.EXTRA_TOKEN_ERROR_DESCRIPTION +import io.mockk.CapturingSlot import io.mockk.every import io.mockk.just import io.mockk.mockk @@ -600,5 +603,88 @@ class ClientManagerMockTest { } Assert.assertEquals(ClientManager.ACCESS_TOKEN_REVOKE_INTENT, broadcastIntentSlot.captured.action) } + + private data class TokenErrorResult( + val authTokenProvider: ClientManager.AccMgrAuthTokenProvider, + val broadcastIntentSlot: CapturingSlot, + val mockAccount: Account, + ) + + private fun setupTokenErrorScenario(error: String, errorDescription: String): TokenErrorResult { + val errorBody = """ + {"error": "$error", "error_description": "$errorDescription"} + """.trimIndent().toResponseBody("application/json; charset=utf-8".toMediaType()) + every { HttpAccess.DEFAULT.okHttpClient } returns mockk { + every { newCall(any()) } returns mockk { + every { execute() } returns mockk(relaxed = true) { + every { isSuccessful } returns false + every { code } returns 400 + every { body } returns errorBody + } + } + } + val mockAccount = mockk(relaxed = true) + val mockUser = mockk(relaxed = true) { + every { authToken } returns OLD_ACCESS_TOKEN + every { refreshToken } returns REFRESH_TOKEN + every { loginServer } returns "https://login.salesforce.com" + } + val clientManagerSpy = spyk(clientManager) + every { clientManagerSpy.accounts } returns arrayOf(mockAccount) + every { mockUserAccountManager.currentUser } returns mockUser + every { mockUserAccountManager.buildUserAccount(mockAccount) } returns mockUser + + val authTokenProvider = ClientManager.AccMgrAuthTokenProvider( + clientManagerSpy, + "https://login.salesforce.com", + OLD_ACCESS_TOKEN, + REFRESH_TOKEN, + ) + return TokenErrorResult(authTokenProvider, slot(), mockAccount) + } + + @Test + fun testGetNewAuthToken_UserBlocked_LogsOutWithUserBlockedReason() { + val result = setupTokenErrorScenario("user_blocked", "Device failed integrity check") + + Assert.assertNull(result.authTokenProvider.getNewAuthToken()) + verify(exactly = 1) { + mockSDKManager.logout(result.mockAccount, any(), true, OAuth2.LogoutReason.USER_BLOCKED) + mockAppContext.sendBroadcast(capture(result.broadcastIntentSlot)) + } + Assert.assertEquals(ClientManager.ACCESS_TOKEN_REVOKE_INTENT, result.broadcastIntentSlot.captured.action) + Assert.assertEquals("user_blocked", result.broadcastIntentSlot.captured.getStringExtra(EXTRA_TOKEN_ERROR)) + Assert.assertEquals("Device failed integrity check", result.broadcastIntentSlot.captured.getStringExtra(EXTRA_TOKEN_ERROR_DESCRIPTION)) + } + + @Test + fun testGetNewAuthToken_UserBlockedRetry_DoesNotLogout() { + val result = setupTokenErrorScenario("user_blocked_retry", "Attestation verification pending") + + Assert.assertNull(result.authTokenProvider.getNewAuthToken()) + verify(exactly = 0) { + mockSDKManager.logout(any(), any(), any(), any()) + } + verify(exactly = 1) { + mockAppContext.sendBroadcast(capture(result.broadcastIntentSlot)) + } + Assert.assertEquals(ClientManager.ACCESS_TOKEN_REVOKE_INTENT, result.broadcastIntentSlot.captured.action) + Assert.assertEquals("user_blocked_retry", result.broadcastIntentSlot.captured.getStringExtra(EXTRA_TOKEN_ERROR)) + Assert.assertEquals("Attestation verification pending", result.broadcastIntentSlot.captured.getStringExtra(EXTRA_TOKEN_ERROR_DESCRIPTION)) + } + + @Test + fun testGetNewAuthToken_InvalidGrant_LogsOutWithRefreshTokenExpired() { + val result = setupTokenErrorScenario("invalid_grant", "expired authorization code") + + Assert.assertNull(result.authTokenProvider.getNewAuthToken()) + verify(exactly = 1) { + mockSDKManager.logout(result.mockAccount, any(), true, OAuth2.LogoutReason.REFRESH_TOKEN_EXPIRED) + mockAppContext.sendBroadcast(capture(result.broadcastIntentSlot)) + } + Assert.assertEquals(ClientManager.ACCESS_TOKEN_REVOKE_INTENT, result.broadcastIntentSlot.captured.action) + Assert.assertEquals("invalid_grant", result.broadcastIntentSlot.captured.getStringExtra(EXTRA_TOKEN_ERROR)) + Assert.assertEquals("expired authorization code", result.broadcastIntentSlot.captured.getStringExtra(EXTRA_TOKEN_ERROR_DESCRIPTION)) + } }