Skip to content

Commit bcf8970

Browse files
@W-23201591: [Android] Surface RTR state in developer info screen
1 parent 1563a2b commit bcf8970

13 files changed

Lines changed: 513 additions & 6 deletions

File tree

libs/SalesforceSDK/src/com/salesforce/androidsdk/accounts/UserAccount.java

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,7 @@ public class UserAccount {
107107
public static final String FEATURE_FLAGS = "feature_flags";
108108
public static final String CREDENTIALS_IDENTIFIER = "credentialsIdentifier";
109109
public static final String TOKEN_TYPE = "tokenType";
110+
public static final String LAST_TOKEN_ROTATION_TIME = "lastTokenRotationTime";
110111

111112
private static final String TAG = "UserAccount";
112113
private static final String FORWARD_SLASH = "/";
@@ -156,6 +157,7 @@ public class UserAccount {
156157
private String scope;
157158
private String credentialsIdentifier;
158159
private String tokenType;
160+
private String lastTokenRotationTime;
159161
private Set<String> featureFlags = new java.util.HashSet<>();
160162

161163
/**
@@ -301,6 +303,7 @@ public class UserAccount {
301303
scope = object.optString(SCOPE, null);
302304
credentialsIdentifier = object.optString(CREDENTIALS_IDENTIFIER, null);
303305
tokenType = object.optString(TOKEN_TYPE, null);
306+
lastTokenRotationTime = object.optString(LAST_TOKEN_ROTATION_TIME, null);
304307
additionalOauthValues = MapUtil.addJSONObjectToMap(object, additionalOauthKeys, additionalOauthValues);
305308
}
306309
}
@@ -360,6 +363,7 @@ public UserAccount(JSONObject object) {
360363
scope = bundle.getString(SCOPE);
361364
credentialsIdentifier = bundle.getString(CREDENTIALS_IDENTIFIER);
362365
tokenType = bundle.getString(TOKEN_TYPE);
366+
lastTokenRotationTime = bundle.getString(LAST_TOKEN_ROTATION_TIME);
363367
additionalOauthValues = MapUtil.addBundleToMap(bundle, additionalOauthKeys, additionalOauthValues);
364368
}
365369
}
@@ -785,6 +789,29 @@ public String getTokenType() {
785789
public void setTokenType(String tokenType) {
786790
this.tokenType = tokenType;
787791
}
792+
793+
/**
794+
* Returns the ISO-8601 timestamp of the last confirmed Refresh Token
795+
* Rotation (RTR) for this user, or null if the refresh token has never
796+
* been rotated.
797+
*
798+
* @return Last token rotation timestamp, or null if not yet rotated.
799+
*/
800+
public String getLastTokenRotationTime() {
801+
return lastTokenRotationTime;
802+
}
803+
804+
/**
805+
* Sets the ISO-8601 timestamp of the last confirmed Refresh Token
806+
* Rotation (RTR).
807+
*
808+
* @param lastTokenRotationTime ISO-8601 timestamp of the last confirmed
809+
* rotation.
810+
*/
811+
public void setLastTokenRotationTime(String lastTokenRotationTime) {
812+
this.lastTokenRotationTime = lastTokenRotationTime;
813+
}
814+
788815
/**
789816
* Returns the beacon child consumer key.
790817
*
@@ -1096,6 +1123,7 @@ JSONObject toJson(List<String> additionalOauthKeys) {
10961123
object.put(SCOPE, scope);
10971124
if (credentialsIdentifier != null) object.put(CREDENTIALS_IDENTIFIER, credentialsIdentifier);
10981125
if (tokenType != null) object.put(TOKEN_TYPE, tokenType);
1126+
if (lastTokenRotationTime != null) object.put(LAST_TOKEN_ROTATION_TIME, lastTokenRotationTime);
10991127
if (!featureFlags.isEmpty()) {
11001128
org.json.JSONArray flagsArray = new org.json.JSONArray();
11011129
for (String f : featureFlags) flagsArray.put(f);
@@ -1164,6 +1192,7 @@ Bundle toBundle(List<String> additionalOauthKeys) {
11641192
object.putString(SCOPE, scope);
11651193
if (credentialsIdentifier != null) object.putString(CREDENTIALS_IDENTIFIER, credentialsIdentifier);
11661194
if (tokenType != null) object.putString(TOKEN_TYPE, tokenType);
1195+
if (lastTokenRotationTime != null) object.putString(LAST_TOKEN_ROTATION_TIME, lastTokenRotationTime);
11671196
object = MapUtil.addMapToBundle(additionalOauthValues, additionalOauthKeys, object);
11681197
return object;
11691198
}

libs/SalesforceSDK/src/com/salesforce/androidsdk/accounts/UserAccountBuilder.kt

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@ class UserAccountBuilder private constructor() {
7474
private var scope: String? = null
7575
private var credentialsIdentifier: String? = null
7676
private var tokenType: String? = null
77+
private var lastTokenRotationTime: String? = null
7778

7879
/**
7980
* Set fields from token end point response
@@ -181,6 +182,7 @@ class UserAccountBuilder private constructor() {
181182
.scope(userAccount.scope)
182183
.credentialsIdentifier(userAccount.credentialsIdentifier)
183184
.tokenType(userAccount.tokenType)
185+
.lastTokenRotationTime(userAccount.lastTokenRotationTime)
184186
}
185187

186188
/**
@@ -610,6 +612,18 @@ class UserAccountBuilder private constructor() {
610612
return if (!allowUnset && tokenType == null) this else apply { this.tokenType = tokenType }
611613
}
612614

615+
/**
616+
* Sets the ISO-8601 timestamp of the last confirmed Refresh Token
617+
* Rotation (RTR).
618+
*
619+
* @param lastTokenRotationTime ISO-8601 timestamp of the last confirmed
620+
* rotation.
621+
* @return Instance of this class.
622+
*/
623+
fun lastTokenRotationTime(lastTokenRotationTime: String?): UserAccountBuilder {
624+
return if (!allowUnset && lastTokenRotationTime == null) this else apply { this.lastTokenRotationTime = lastTokenRotationTime }
625+
}
626+
613627
/**
614628
* Builds and returns a UserAccount object.
615629
*
@@ -658,6 +672,7 @@ class UserAccountBuilder private constructor() {
658672
)
659673
account.credentialsIdentifier = credentialsIdentifier
660674
account.tokenType = tokenType
675+
account.lastTokenRotationTime = lastTokenRotationTime
661676
return account
662677
}
663678

libs/SalesforceSDK/src/com/salesforce/androidsdk/accounts/UserAccountManager.java

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -558,6 +558,7 @@ public Bundle updateAccount(Account account, UserAccount userAccount) {
558558
final String scope = decryptUserData(account, AuthenticatorService.KEY_SCOPE, encryptionKey);
559559
final String credentialsIdentifier = decryptUserData(account, AuthenticatorService.KEY_CREDENTIALS_IDENTIFIER, encryptionKey);
560560
final String tokenType = decryptUserData(account, AuthenticatorService.KEY_TOKEN_TYPE, encryptionKey);
561+
final String lastTokenRotationTime = decryptUserData(account, AuthenticatorService.KEY_LAST_TOKEN_ROTATION_TIME, encryptionKey);
561562
final String featureFlagsRaw = decryptUserData(account, AuthenticatorService.KEY_FEATURE_FLAGS, encryptionKey);
562563

563564
Map<String, String> additionalOauthValues = null;
@@ -616,6 +617,7 @@ public Bundle updateAccount(Account account, UserAccount userAccount) {
616617
.scope(scope)
617618
.credentialsIdentifier(credentialsIdentifier)
618619
.tokenType(tokenType)
620+
.lastTokenRotationTime(lastTokenRotationTime)
619621
.additionalOauthValues(additionalOauthValues)
620622
.build();
621623
if (!TextUtils.isEmpty(featureFlagsRaw)) {
@@ -776,6 +778,9 @@ private Bundle buildAuthBundle(UserAccount userAccount) {
776778
if (userAccount.getTokenType() != null) {
777779
extras.putString(AuthenticatorService.KEY_TOKEN_TYPE, SalesforceSDKManager.encrypt(userAccount.getTokenType(), encryptionKey));
778780
}
781+
if (userAccount.getLastTokenRotationTime() != null) {
782+
extras.putString(AuthenticatorService.KEY_LAST_TOKEN_ROTATION_TIME, SalesforceSDKManager.encrypt(userAccount.getLastTokenRotationTime(), encryptionKey));
783+
}
779784
final Set<String> featureFlags = userAccount.getFeatureFlags();
780785
if (!featureFlags.isEmpty()) {
781786
extras.putString(AuthenticatorService.KEY_FEATURE_FLAGS,

libs/SalesforceSDK/src/com/salesforce/androidsdk/app/SalesforceSDKManager.kt

Lines changed: 78 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,9 @@ import android.text.TextUtils.join
5353
import android.view.WindowInsetsController.APPEARANCE_LIGHT_STATUS_BARS
5454
import android.webkit.CookieManager
5555
import android.webkit.URLUtil.isHttpsUrl
56+
import android.widget.Toast
5657
import androidx.annotation.VisibleForTesting
58+
import androidx.annotation.VisibleForTesting.Companion.PRIVATE
5759
import androidx.annotation.VisibleForTesting.Companion.PROTECTED
5860
import androidx.compose.material3.ColorScheme
5961
import androidx.compose.runtime.Composable
@@ -147,6 +149,7 @@ import kotlinx.coroutines.CoroutineScope
147149
import kotlinx.coroutines.Dispatchers.Default
148150
import kotlinx.coroutines.Dispatchers.Main
149151
import kotlinx.coroutines.launch
152+
import kotlinx.coroutines.withContext
150153
import kotlinx.coroutines.withTimeoutOrNull
151154
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
152155
import java.lang.String.CASE_INSENSITIVE_ORDER
@@ -1385,6 +1388,21 @@ open class SalesforceSDKManager protected constructor(
13851388
*/
13861389
fun isGlobalFeatureRegistered(appFeatureCode: String) = features.contains(appFeatureCode)
13871390

1391+
/**
1392+
* Returns true if the feature code is registered for the given user
1393+
* (falling back to the current user when [user] is null). Reads the
1394+
* per-user feature set that backs the user agent's ftr_ token, so this
1395+
* reflects features such as RTR that are registered per account.
1396+
*
1397+
* @param appFeatureCode The app feature code
1398+
* @param user The user account, or null to use the current user
1399+
*/
1400+
internal fun isUserFeatureRegistered(appFeatureCode: String, user: UserAccount? = null): Boolean {
1401+
val resolvedUser = user ?: userAccountManager.currentUser ?: return false
1402+
val key = "${resolvedUser.orgId}/${resolvedUser.userId}"
1403+
return perUserFeatures[key]?.contains(appFeatureCode) == true
1404+
}
1405+
13881406
/**
13891407
* Adds a per-user app feature code for reporting in the user agent header.
13901408
* Falls back to the global set when user is null.
@@ -1568,11 +1586,57 @@ open class SalesforceSDKManager protected constructor(
15681586
})
15691587
}
15701588
}
1589+
1590+
/*
1591+
* Debug-only helper: proactively drive the SDK's standard
1592+
* token-refresh path so developers can observe Refresh Token
1593+
* Rotation (RTR) state update in the dev info screen without
1594+
* waiting for the access token to expire naturally. This whole
1595+
* menu is only shown when isDevSupportEnabled() is true (debug
1596+
* builds by default).
1597+
*/
1598+
actions["Force Token Refresh"] = object : DevActionHandler {
1599+
override fun onSelected() {
1600+
val user = userAccountManager.currentUser ?: return
1601+
CoroutineScope(Default).launch {
1602+
val message = forceTokenRefresh(user)
1603+
withContext(Main) {
1604+
Toast.makeText(appContext, message, Toast.LENGTH_LONG).show()
1605+
}
1606+
}
1607+
}
1608+
}
15711609
}
15721610

15731611
return actions
15741612
}
15751613

1614+
/**
1615+
* Drives the SDK's standard token-refresh path for [user] so developers
1616+
* can observe Refresh Token Rotation (RTR) state update in the dev info
1617+
* screen without waiting for the access token to expire naturally. Backs
1618+
* the debug-only "Force Token Refresh" dev action.
1619+
*
1620+
* @param user The user whose access token should be refreshed.
1621+
* @param restClient The REST client to refresh. Defaults to the user's
1622+
* client; overridable so tests can supply a mock without a network call.
1623+
* @return A human-readable result message suitable for a Toast. Never
1624+
* throws — any refresh failure is caught, logged, and returned as a
1625+
* message (with a null-message fallback to the exception's simple class
1626+
* name).
1627+
*/
1628+
@VisibleForTesting(otherwise = PRIVATE)
1629+
internal fun forceTokenRefresh(
1630+
user: UserAccount,
1631+
restClient: RestClient = clientManager.peekRestClient(user)
1632+
): String = try {
1633+
restClient.refreshAccessToken()
1634+
"Token refresh complete — check RTR section in dev info"
1635+
} catch (ex: Exception) {
1636+
e(TAG, "Force Token Refresh failed", ex)
1637+
"Token refresh failed: ${ex.message ?: ex.javaClass.simpleName}"
1638+
}
1639+
15761640
/** Information to display in the developer support dialog */
15771641
@Deprecated(
15781642
"Will be removed in Mobile SDK 14.0, please use the new data class representation.",
@@ -1637,7 +1701,20 @@ open class SalesforceSDKManager protected constructor(
16371701
//
16381702
// TODO: Replace devSupportInfo with the above implementation when devSupportInfos is removed in 14.0.
16391703
open val devSupportInfo: DevSupportInfo
1640-
get() = DevSupportInfo.createFromLegacyDevInfos(devSupportInfos)
1704+
get() = DevSupportInfo.createFromLegacyDevInfos(devSupportInfos).apply {
1705+
/*
1706+
* Surface Refresh Token Rotation (RTR) state so developers can
1707+
* verify whether RTR is active for the current user's session and
1708+
* when the token last rotated.
1709+
*/
1710+
val currentUser = userAccountManager.cachedCurrentUser
1711+
additionalSections.add(
1712+
DevSupportInfo.parseRtrSection(
1713+
currentUser = currentUser,
1714+
rtrActive = currentUser != null && isUserFeatureRegistered(Features.FEATURE_RTR, currentUser),
1715+
)
1716+
)
1717+
}
16411718

16421719
/** Sends the logout completed intent */
16431720
private fun sendLogoutCompleteIntent(logoutReason: LogoutReason, userAccount: UserAccount?) =

libs/SalesforceSDK/src/com/salesforce/androidsdk/auth/AuthenticatorService.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,7 @@ public class AuthenticatorService extends Service {
9393
public static final String KEY_FEATURE_FLAGS = "feature_flags";
9494
public static final String KEY_CREDENTIALS_IDENTIFIER = "credentialsIdentifier";
9595
public static final String KEY_TOKEN_TYPE = "tokenType";
96+
public static final String KEY_LAST_TOKEN_ROTATION_TIME = "lastTokenRotationTime";
9697

9798
private static final String TAG = "AuthenticatorService";
9899

libs/SalesforceSDK/src/com/salesforce/androidsdk/developer/support/DevSupportInfo.kt

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,31 @@ data class DevSupportInfo(
188188
return "Current User" to rows
189189
}
190190

191+
/**
192+
* Builds the "RTR" (Refresh Token Rotation) section for the developer
193+
* info screen.
194+
*
195+
* @param currentUser The current user account, or null if no user is
196+
* logged in.
197+
* @param rtrActive True if the RTR feature flag (ftr_RT) is registered
198+
* for the current user.
199+
* @return An "RTR" section with "RTR Active" and "Last Rotation" rows.
200+
* Per-user fields show "N/A" when there is no current user; "Last
201+
* Rotation" shows "Never" until the first confirmed rotation.
202+
*/
203+
internal fun parseRtrSection(currentUser: UserAccount?, rtrActive: Boolean) =
204+
if (currentUser == null) {
205+
"RTR" to listOf(
206+
"RTR Active" to "N/A",
207+
"Last Rotation" to "N/A",
208+
)
209+
} else {
210+
"RTR" to listOf(
211+
"RTR Active" to rtrActive.toString(),
212+
"Last Rotation" to (currentUser.lastTokenRotationTime?.ifBlank { "Never" } ?: "Never"),
213+
)
214+
}
215+
191216
fun parseRuntimeConfig(config: RuntimeConfig): DevInfoList {
192217
val values = mutableListOf(
193218
"Managed App" to config.isManagedApp.toString()

libs/SalesforceSDK/src/com/salesforce/androidsdk/rest/ClientManager.java

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@
6060

6161
import java.net.URI;
6262
import java.net.URISyntaxException;
63+
import java.time.Instant;
6364
import java.util.Map;
6465
import java.util.concurrent.ConcurrentHashMap;
6566

@@ -793,15 +794,31 @@ private UserAccount refreshStaleToken(Account account) throws NetworkErrorExcept
793794
.populateFromTokenEndpointResponse(tr)
794795
.build();
795796

797+
/*
798+
* Detect server-side Refresh Token Rotation: the response
799+
* carried a refresh token that differs from this provider's
800+
* cached copy. Stamp the ISO-8601 rotation time on the account
801+
* BEFORE the primary persist below so the timestamp is written
802+
* by the authoritative updateAccount call, not as a side
803+
* effect of feature-flag registration.
804+
*/
805+
boolean refreshTokenRotated = tr.refreshToken != null && !tr.refreshToken.equals(refreshToken);
806+
if (refreshTokenRotated) {
807+
updatedUserAccount.setLastTokenRotationTime(Instant.now().toString());
808+
}
809+
796810
UserAccountManager.getInstance().updateAccount(account, updatedUserAccount);
797811
updatedUserAccount.downloadProfilePhoto();
798812
UserAccountManager.getInstance().clearCachedCurrentUser();
799813

800-
// Handle server-side Refresh Token Rotation: if the response contained a new refresh token,
801-
// update this provider's cached copy.
802-
if (tr.refreshToken != null && !tr.refreshToken.equals(refreshToken)) {
814+
if (refreshTokenRotated) {
815+
/*
816+
* Update this provider's cached copy and surface RTR as a
817+
* per-user feature flag. The rotation timestamp is already
818+
* persisted (above), so RTR-Active state here is
819+
* independent of the timestamp's durability.
820+
*/
803821
refreshToken = tr.refreshToken;
804-
// Surface RTR as a per-user feature flag
805822
SalesforceSDKManager.getInstance().registerUsedAppFeature(Features.FEATURE_RTR, updatedUserAccount);
806823
}
807824

libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/accounts/UserAccountManagerTest.java

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -246,6 +246,34 @@ public void test_givenDPoPAccount_whenUpdateAccount_thenCredentialsIdentifierAnd
246246
newTokenType, restored.getTokenType());
247247
}
248248

249+
/*
250+
* The RTR rotation timestamp must survive the full AccountManager
251+
* persistence path (encrypt on updateAccount → decrypt on
252+
* buildUserAccount), not just in-memory JSON/Bundle, so the "Last
253+
* Rotation" value persists across an app restart. createTestAccount()
254+
* leaves lastTokenRotationTime null, so this is the only test that
255+
* exercises the new KEY_LAST_TOKEN_ROTATION_TIME encrypt/decrypt branch.
256+
*/
257+
@Test
258+
public void test_givenRotatedAccount_whenUpdateAccount_thenLastTokenRotationTimeRoundTrips() {
259+
UserAccount original = UserAccountTest.createTestAccount();
260+
Assert.assertNull("Precondition: rotation timestamp must start unset",
261+
original.getLastTokenRotationTime());
262+
userAccMgr.createAccount(original);
263+
Account account = userAccMgr.getCurrentAccount();
264+
265+
final String rotationTime = "2026-07-30T12:34:56Z";
266+
UserAccount rotated = UserAccountBuilder.getInstance()
267+
.populateFromUserAccount(original)
268+
.lastTokenRotationTime(rotationTime)
269+
.build();
270+
userAccMgr.updateAccount(account, rotated);
271+
272+
UserAccount restored = userAccMgr.buildUserAccount(account);
273+
Assert.assertEquals("lastTokenRotationTime must survive updateAccount → buildUserAccount round-trip",
274+
rotationTime, restored.getLastTokenRotationTime());
275+
}
276+
249277
/**
250278
* Test to get all authenticated users.
251279
*/

0 commit comments

Comments
 (0)