Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,7 @@ public class UserAccount {
public static final String FEATURE_FLAGS = "feature_flags";
public static final String CREDENTIALS_IDENTIFIER = "credentialsIdentifier";
public static final String TOKEN_TYPE = "tokenType";
public static final String LAST_TOKEN_ROTATION_TIME = "lastTokenRotationTime";

private static final String TAG = "UserAccount";
private static final String FORWARD_SLASH = "/";
Expand Down Expand Up @@ -156,6 +157,7 @@ public class UserAccount {
private String scope;
private String credentialsIdentifier;
private String tokenType;
private String lastTokenRotationTime;
private Set<String> featureFlags = new java.util.HashSet<>();

/**
Expand Down Expand Up @@ -301,6 +303,7 @@ public class UserAccount {
scope = object.optString(SCOPE, null);
credentialsIdentifier = object.optString(CREDENTIALS_IDENTIFIER, null);
tokenType = object.optString(TOKEN_TYPE, null);
lastTokenRotationTime = object.optString(LAST_TOKEN_ROTATION_TIME, null);
additionalOauthValues = MapUtil.addJSONObjectToMap(object, additionalOauthKeys, additionalOauthValues);
}
}
Expand Down Expand Up @@ -360,6 +363,7 @@ public UserAccount(JSONObject object) {
scope = bundle.getString(SCOPE);
credentialsIdentifier = bundle.getString(CREDENTIALS_IDENTIFIER);
tokenType = bundle.getString(TOKEN_TYPE);
lastTokenRotationTime = bundle.getString(LAST_TOKEN_ROTATION_TIME);
additionalOauthValues = MapUtil.addBundleToMap(bundle, additionalOauthKeys, additionalOauthValues);
}
}
Expand Down Expand Up @@ -785,6 +789,29 @@ public String getTokenType() {
public void setTokenType(String tokenType) {
this.tokenType = tokenType;
}

/**
* Returns the ISO-8601 timestamp of the last confirmed Refresh Token
* Rotation (RTR) for this user, or null if the refresh token has never
* been rotated.
*
* @return Last token rotation timestamp, or null if not yet rotated.
*/
public String getLastTokenRotationTime() {
return lastTokenRotationTime;
}

/**
* Sets the ISO-8601 timestamp of the last confirmed Refresh Token
* Rotation (RTR).
*
* @param lastTokenRotationTime ISO-8601 timestamp of the last confirmed
* rotation.
*/
public void setLastTokenRotationTime(String lastTokenRotationTime) {
this.lastTokenRotationTime = lastTokenRotationTime;
}
Comment on lines +811 to +813

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.

It will be nice when everything is Kotlin and we can make things like this internal. Not necessary for this PR, but curious if you have an opinion on RestrictTo? It does not actually prevent someone from using the API, but adds a stern lint warning/error indicating that we do not want them to.

@JohnsonEricAtSalesforce JohnsonEricAtSalesforce Jul 31, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Agree that's the right direction for library-internal API that has to stay public from Java. I'd rather not add it to just these two accessors here, since the sibling UserAccount accessors (e.g. tokenType) aren't annotated and a partial application would be inconsistent — better as a uniform sweep across the account accessors. Filed W-23667824 to track that. Out of scope for this PR.

This response was generated by an AI agent on behalf of @JohnsonEricAtSalesforce.


/**
* Returns the beacon child consumer key.
*
Expand Down Expand Up @@ -1096,6 +1123,7 @@ JSONObject toJson(List<String> additionalOauthKeys) {
object.put(SCOPE, scope);
if (credentialsIdentifier != null) object.put(CREDENTIALS_IDENTIFIER, credentialsIdentifier);
if (tokenType != null) object.put(TOKEN_TYPE, tokenType);
if (lastTokenRotationTime != null) object.put(LAST_TOKEN_ROTATION_TIME, lastTokenRotationTime);
if (!featureFlags.isEmpty()) {
org.json.JSONArray flagsArray = new org.json.JSONArray();
for (String f : featureFlags) flagsArray.put(f);
Expand Down Expand Up @@ -1164,6 +1192,7 @@ Bundle toBundle(List<String> additionalOauthKeys) {
object.putString(SCOPE, scope);
if (credentialsIdentifier != null) object.putString(CREDENTIALS_IDENTIFIER, credentialsIdentifier);
if (tokenType != null) object.putString(TOKEN_TYPE, tokenType);
if (lastTokenRotationTime != null) object.putString(LAST_TOKEN_ROTATION_TIME, lastTokenRotationTime);
object = MapUtil.addMapToBundle(additionalOauthValues, additionalOauthKeys, object);
return object;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ class UserAccountBuilder private constructor() {
private var scope: String? = null
private var credentialsIdentifier: String? = null
private var tokenType: String? = null
private var lastTokenRotationTime: String? = null

/**
* Set fields from token end point response
Expand Down Expand Up @@ -181,6 +182,7 @@ class UserAccountBuilder private constructor() {
.scope(userAccount.scope)
.credentialsIdentifier(userAccount.credentialsIdentifier)
.tokenType(userAccount.tokenType)
.lastTokenRotationTime(userAccount.lastTokenRotationTime)
}

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

/**
* Sets the ISO-8601 timestamp of the last confirmed Refresh Token
* Rotation (RTR).
*
* @param lastTokenRotationTime ISO-8601 timestamp of the last confirmed
* rotation.
* @return Instance of this class.
*/
fun lastTokenRotationTime(lastTokenRotationTime: String?): UserAccountBuilder {
return if (!allowUnset && lastTokenRotationTime == null) this else apply { this.lastTokenRotationTime = lastTokenRotationTime }
}

/**
* Builds and returns a UserAccount object.
*
Expand Down Expand Up @@ -658,6 +672,7 @@ class UserAccountBuilder private constructor() {
)
account.credentialsIdentifier = credentialsIdentifier
account.tokenType = tokenType
account.lastTokenRotationTime = lastTokenRotationTime
return account
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -558,6 +558,7 @@ public Bundle updateAccount(Account account, UserAccount userAccount) {
final String scope = decryptUserData(account, AuthenticatorService.KEY_SCOPE, encryptionKey);
final String credentialsIdentifier = decryptUserData(account, AuthenticatorService.KEY_CREDENTIALS_IDENTIFIER, encryptionKey);
final String tokenType = decryptUserData(account, AuthenticatorService.KEY_TOKEN_TYPE, encryptionKey);
final String lastTokenRotationTime = decryptUserData(account, AuthenticatorService.KEY_LAST_TOKEN_ROTATION_TIME, encryptionKey);
final String featureFlagsRaw = decryptUserData(account, AuthenticatorService.KEY_FEATURE_FLAGS, encryptionKey);

Map<String, String> additionalOauthValues = null;
Expand Down Expand Up @@ -616,6 +617,7 @@ public Bundle updateAccount(Account account, UserAccount userAccount) {
.scope(scope)
.credentialsIdentifier(credentialsIdentifier)
.tokenType(tokenType)
.lastTokenRotationTime(lastTokenRotationTime)
.additionalOauthValues(additionalOauthValues)
.build();
if (!TextUtils.isEmpty(featureFlagsRaw)) {
Expand Down Expand Up @@ -776,6 +778,9 @@ private Bundle buildAuthBundle(UserAccount userAccount) {
if (userAccount.getTokenType() != null) {
extras.putString(AuthenticatorService.KEY_TOKEN_TYPE, SalesforceSDKManager.encrypt(userAccount.getTokenType(), encryptionKey));
}
if (userAccount.getLastTokenRotationTime() != null) {
extras.putString(AuthenticatorService.KEY_LAST_TOKEN_ROTATION_TIME, SalesforceSDKManager.encrypt(userAccount.getLastTokenRotationTime(), encryptionKey));
}
final Set<String> featureFlags = userAccount.getFeatureFlags();
if (!featureFlags.isEmpty()) {
extras.putString(AuthenticatorService.KEY_FEATURE_FLAGS,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,9 @@ import android.text.TextUtils.join
import android.view.WindowInsetsController.APPEARANCE_LIGHT_STATUS_BARS
import android.webkit.CookieManager
import android.webkit.URLUtil.isHttpsUrl
import android.widget.Toast
import androidx.annotation.VisibleForTesting
import androidx.annotation.VisibleForTesting.Companion.PRIVATE
import androidx.annotation.VisibleForTesting.Companion.PROTECTED
import androidx.compose.material3.ColorScheme
import androidx.compose.runtime.Composable
Expand Down Expand Up @@ -147,6 +149,7 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers.Default
import kotlinx.coroutines.Dispatchers.Main
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeoutOrNull
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import java.lang.String.CASE_INSENSITIVE_ORDER
Expand Down Expand Up @@ -1385,6 +1388,21 @@ open class SalesforceSDKManager protected constructor(
*/
fun isGlobalFeatureRegistered(appFeatureCode: String) = features.contains(appFeatureCode)

/**
* Returns true if the feature code is registered for the given user
* (falling back to the current user when [user] is null). Reads the
* per-user feature set that backs the user agent's ftr_ token, so this
* reflects features such as RTR that are registered per account.
*
* @param appFeatureCode The app feature code
* @param user The user account, or null to use the current user
*/
internal fun isUserFeatureRegistered(appFeatureCode: String, user: UserAccount? = null): Boolean {

@JohnsonEricAtSalesforce JohnsonEricAtSalesforce Jul 31, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

New read accessor for per-user feature flags. RTR-active state is stored as the per-user feature flag RT that ClientManager registers on a confirmed rotation. The backing perUserFeatures map is private and the existing public surface only lets you write (registerUsedAppFeature) or read the whole aggregated user-agent string — there was no narrow "is this one code set for this user?" read.

Rather than widen perUserFeatures visibility, this adds a focused internal accessor. It resolves the target user (explicit arg → current user → false if neither), builds the same "orgId/userId" key the writers use (registerUsedAppFeature/unregisterUsedAppFeature/getUserAgent), and does a null-safe, thread-safe (ConcurrentHashMap/ConcurrentSkipListSet), case-insensitive membership test. The optional user param defaults to the current user for ergonomics; the sole caller today passes the user explicitly, and the fallback branch is covered by its own test.

This response was generated by an AI agent on behalf of @JohnsonEricAtSalesforce.

val resolvedUser = user ?: userAccountManager.currentUser ?: return false
val key = "${resolvedUser.orgId}/${resolvedUser.userId}"
return perUserFeatures[key]?.contains(appFeatureCode) == true
}

/**
* Adds a per-user app feature code for reporting in the user agent header.
* Falls back to the global set when user is null.
Expand Down Expand Up @@ -1568,11 +1586,57 @@ open class SalesforceSDKManager protected constructor(
})
}
}

/*
* Debug-only helper: proactively drive the SDK's standard
* token-refresh path so developers can observe Refresh Token
* Rotation (RTR) state update in the dev info screen without
* waiting for the access token to expire naturally. This whole
* menu is only shown when isDevSupportEnabled() is true (debug
* builds by default).
*/
actions["Force Token Refresh"] = object : DevActionHandler {

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.

Great idea.

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.

@sfdctaka Should we add this to iOS as well?

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.

@brandonpage Ditto. Let me file a ticket for it and we can take care of it next week.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Sounds good — thanks for filing the iOS ticket, @sfdctaka. Happy to help with the parity work when it's up. Likewise the AuthFlowTester RTR UI is a nice follow-up for another day.

This response was generated by an AI agent on behalf of @JohnsonEricAtSalesforce.

override fun onSelected() {
val user = userAccountManager.currentUser ?: return
CoroutineScope(Default).launch {
val message = forceTokenRefresh(user)
withContext(Main) {
Toast.makeText(appContext, message, Toast.LENGTH_LONG).show()
}
}
}
}
}

return actions
}

/**
* Drives the SDK's standard token-refresh path for [user] so developers
* can observe Refresh Token Rotation (RTR) state update in the dev info
* screen without waiting for the access token to expire naturally. Backs
* the debug-only "Force Token Refresh" dev action.
*
* @param user The user whose access token should be refreshed.
* @param restClient The REST client to refresh. Defaults to the user's
* client; overridable so tests can supply a mock without a network call.
* @return A human-readable result message suitable for a Toast. Never
* throws — any refresh failure is caught, logged, and returned as a
* message (with a null-message fallback to the exception's simple class
* name).
*/
@VisibleForTesting(otherwise = PRIVATE)
internal fun forceTokenRefresh(
user: UserAccount,
restClient: RestClient = clientManager.peekRestClient(user)

@JohnsonEricAtSalesforce JohnsonEricAtSalesforce Jul 31, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Default-argument seam instead of widening production surface. forceTokenRefresh backs the debug-only "Force Token Refresh" dev action; it drives the SDK's standard refresh path so a developer can watch RTR state update without waiting for natural token expiry. It's the testable core — the dev-action lambda only dispatches it on a coroutine and shows the result in a Toast.

To make it testable without a network call, restClient defaults to clientManager.peekRestClient(user) (the production path, unchanged at the forceTokenRefresh(user) call site) and tests pass a mock RestClient. This mirrors the existing invokeServerNotificationAction(..., restClient: RestClient = clientManager.peekRestClient(...)) idiom in this same file. An earlier revision made clientManager open for a test subclass to override — this seam replaces that, so there is no production-visibility change. It's @VisibleForTesting(otherwise = PRIVATE) internal, and it never throws: any failure is caught, logged, and returned as a message.

This response was generated by an AI agent on behalf of @JohnsonEricAtSalesforce.

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.

Could peekRestClient throw?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Good catch — yes. peekRestClient(user) throws AccountInfoNotFoundException (a RuntimeException) when there's no account, the user is mid-logout, or auth-token/URL/id data is missing. Because it was a default argument it was evaluated before the try, so it could escape uncaught — contradicting the "never throws" contract. Fixed by resolving the client inside the try ((restClient ?: clientManager.peekRestClient(user))) and correcting the KDoc, plus a regression test covering the default path. Commit: dd055af

This response was generated by an AI agent on behalf of @JohnsonEricAtSalesforce.

): String = try {
restClient.refreshAccessToken()
"Token refresh complete — check RTR section in dev info"
} catch (ex: Exception) {
e(TAG, "Force Token Refresh failed", ex)
"Token refresh failed: ${ex.message ?: ex.javaClass.simpleName}"
}

/** Information to display in the developer support dialog */
@Deprecated(
"Will be removed in Mobile SDK 14.0, please use the new data class representation.",
Expand Down Expand Up @@ -1637,7 +1701,20 @@ open class SalesforceSDKManager protected constructor(
//
// TODO: Replace devSupportInfo with the above implementation when devSupportInfos is removed in 14.0.
open val devSupportInfo: DevSupportInfo
get() = DevSupportInfo.createFromLegacyDevInfos(devSupportInfos)
get() = DevSupportInfo.createFromLegacyDevInfos(devSupportInfos).apply {
/*
* Surface Refresh Token Rotation (RTR) state so developers can
* verify whether RTR is active for the current user's session and
* when the token last rotated.
*/
val currentUser = userAccountManager.cachedCurrentUser
additionalSections.add(

@JohnsonEricAtSalesforce JohnsonEricAtSalesforce Jul 31, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Why the RTR section is appended via additionalSections rather than a new field. createFromLegacyDevInfos(devSupportInfos) returns a DevSupportInfo that is already fully populated — its five standard sections (basic info, auth config, boot config, current user, runtime config) are immutable vals set at construction. additionalSections is the only mutable member, and it's the designed extension point for sections the fixed schema didn't anticipate. So .apply { additionalSections.add(...) } is the one legal, minimal-touch way to inject RTR post-construction.

Deliberately not done: (a) editing createFromLegacyDevInfos, which is marked // TODO: Remove in 14.0; (b) adding a dedicated rtrSection field, which would change the data-class signature and require wiring into both the legacy path and the future implementation.

⚠️ 14.0 migration note for reviewers: the commented-out future devSupportInfo getter just above builds DevSupportInfo via its structured constructor and does not append this RTR section. Whoever swaps that in when devSupportInfos is removed must carry the RTR additionalSections.add(...) over, or RTR silently drops from the screen.

This response was generated by an AI agent on behalf of @JohnsonEricAtSalesforce.

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.

@JohnsonEricAtSalesforce If you cannot add RTR to the new impl comment above, please add a comment noting that RTR needs to be added so it does not get lost.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done — added a note in the commented-out 14.0 devSupportInfo implementation (and expanded the TODO) that the RTR additionalSections.add(...) from the live getter must be carried over, so it can't be missed when the block is swapped in. Commit: dd055af

This response was generated by an AI agent on behalf of @JohnsonEricAtSalesforce.

DevSupportInfo.parseRtrSection(
currentUser = currentUser,
rtrActive = currentUser != null && isUserFeatureRegistered(Features.FEATURE_RTR, currentUser),
)
)
}

/** Sends the logout completed intent */
private fun sendLogoutCompleteIntent(logoutReason: LogoutReason, userAccount: UserAccount?) =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ public class AuthenticatorService extends Service {
public static final String KEY_FEATURE_FLAGS = "feature_flags";
public static final String KEY_CREDENTIALS_IDENTIFIER = "credentialsIdentifier";
public static final String KEY_TOKEN_TYPE = "tokenType";
public static final String KEY_LAST_TOKEN_ROTATION_TIME = "lastTokenRotationTime";

private static final String TAG = "AuthenticatorService";

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,31 @@ data class DevSupportInfo(
return "Current User" to rows
}

/**
* Builds the "RTR" (Refresh Token Rotation) section for the developer
* info screen.
*
* @param currentUser The current user account, or null if no user is
* logged in.
* @param rtrActive True if the RTR feature flag (ftr_RT) is registered
* for the current user.
* @return An "RTR" section with "RTR Active" and "Last Rotation" rows.
* Per-user fields show "N/A" when there is no current user; "Last
* Rotation" shows "Never" until the first confirmed rotation.
*/
internal fun parseRtrSection(currentUser: UserAccount?, rtrActive: Boolean) =
if (currentUser == null) {
"RTR" to listOf(
"RTR Active" to "N/A",
"Last Rotation" to "N/A",
)
} else {
"RTR" to listOf(
"RTR Active" to rtrActive.toString(),
"Last Rotation" to (currentUser.lastTokenRotationTime?.ifBlank { "Never" } ?: "Never"),
)
}

fun parseRuntimeConfig(config: RuntimeConfig): DevInfoList {
val values = mutableListOf(
"Managed App" to config.isManagedApp.toString()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@

import java.net.URI;
import java.net.URISyntaxException;
import java.time.Instant;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;

Expand Down Expand Up @@ -793,15 +794,31 @@ private UserAccount refreshStaleToken(Account account) throws NetworkErrorExcept
.populateFromTokenEndpointResponse(tr)
.build();

/*
* Detect server-side Refresh Token Rotation: the response
* carried a refresh token that differs from this provider's
* cached copy. Stamp the ISO-8601 rotation time on the account
* BEFORE the primary persist below so the timestamp is written
* by the authoritative updateAccount call, not as a side
* effect of feature-flag registration.
*/
boolean refreshTokenRotated = tr.refreshToken != null && !tr.refreshToken.equals(refreshToken);
if (refreshTokenRotated) {
updatedUserAccount.setLastTokenRotationTime(Instant.now().toString());
}

UserAccountManager.getInstance().updateAccount(account, updatedUserAccount);
updatedUserAccount.downloadProfilePhoto();
UserAccountManager.getInstance().clearCachedCurrentUser();

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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,34 @@ public void test_givenDPoPAccount_whenUpdateAccount_thenCredentialsIdentifierAnd
newTokenType, restored.getTokenType());
}

/*
* The RTR rotation timestamp must survive the full AccountManager
* persistence path (encrypt on updateAccount → decrypt on
* buildUserAccount), not just in-memory JSON/Bundle, so the "Last
* Rotation" value persists across an app restart. createTestAccount()
* leaves lastTokenRotationTime null, so this is the only test that
* exercises the new KEY_LAST_TOKEN_ROTATION_TIME encrypt/decrypt branch.
*/
@Test
public void test_givenRotatedAccount_whenUpdateAccount_thenLastTokenRotationTimeRoundTrips() {
UserAccount original = UserAccountTest.createTestAccount();
Assert.assertNull("Precondition: rotation timestamp must start unset",
original.getLastTokenRotationTime());
userAccMgr.createAccount(original);
Account account = userAccMgr.getCurrentAccount();

final String rotationTime = "2026-07-30T12:34:56Z";
UserAccount rotated = UserAccountBuilder.getInstance()
.populateFromUserAccount(original)
.lastTokenRotationTime(rotationTime)
.build();
userAccMgr.updateAccount(account, rotated);

UserAccount restored = userAccMgr.buildUserAccount(account);
Assert.assertEquals("lastTokenRotationTime must survive updateAccount → buildUserAccount round-trip",
rotationTime, restored.getLastTokenRotationTime());
}

/**
* Test to get all authenticated users.
*/
Expand Down
Loading
Loading