Skip to content

Commit 11a4a43

Browse files
authored
Fix Refresh Token Rotation. (#2884)
* Fix Refresh Token Rotation. * Update UserAccount.getRefreshToken to always return the latest values known to the SDK. Add getRefreshTokenForPersistance to handle account updates and storage.
1 parent ce6db13 commit 11a4a43

6 files changed

Lines changed: 298 additions & 8 deletions

File tree

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

Lines changed: 44 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@
2626
*/
2727
package com.salesforce.androidsdk.accounts;
2828

29+
import android.accounts.Account;
30+
import android.accounts.AccountManager;
2931
import android.app.DownloadManager;
3032
import android.content.Context;
3133
import android.content.pm.PackageManager;
@@ -372,6 +374,46 @@ public String getAuthToken() {
372374
* @return Refresh token.
373375
*/
374376
public String getRefreshToken() {
377+
if (accountName != null) {
378+
try {
379+
final AccountManager accMrg = AccountManager.get(SalesforceSDKManager.getInstance().getAppContext());
380+
final String accountType = SalesforceSDKManager.getInstance().getAccountType();
381+
for (Account account : accMrg.getAccountsByType(accountType)) {
382+
if (accountName.equals(account.name)) {
383+
final String encryptedPassword = accMrg.getPassword(account);
384+
if (encryptedPassword != null) {
385+
final String newestRefreshToken = SalesforceSDKManager.decrypt(
386+
encryptedPassword, SalesforceSDKManager.getEncryptionKey());
387+
if (newestRefreshToken != null) {
388+
return newestRefreshToken;
389+
}
390+
}
391+
break;
392+
}
393+
}
394+
} catch (Exception e) {
395+
SalesforceSDKLogger.w(TAG,
396+
"Failed to read latest refresh token from AccountManager; using in-memory value",
397+
e);
398+
}
399+
}
400+
return refreshToken;
401+
}
402+
403+
/**
404+
* Returns the in-memory refresh token snapshot, without consulting
405+
* {@link AccountManager}.
406+
*
407+
* Intended for persistence call sites that are about to write a refresh
408+
* token to storage (account creation, update, token migration, duplicate
409+
* user reconciliation). Using {@link #getRefreshToken()} at those sites
410+
* would read back the value currently persisted in {@link AccountManager}
411+
* — i.e. the value we are about to overwrite — instead of the value the
412+
* caller built this {@code UserAccount} with.
413+
*
414+
* @return The refresh token field as set at construction time.
415+
*/
416+
public String getRefreshTokenForPersistence() {
375417
return refreshToken;
376418
}
377419

@@ -948,7 +990,7 @@ JSONObject toJson(List<String> additionalOauthKeys) {
948990
JSONObject object = new JSONObject();
949991
try {
950992
object.put(AUTH_TOKEN, authToken);
951-
object.put(REFRESH_TOKEN, refreshToken);
993+
object.put(REFRESH_TOKEN, getRefreshToken());
952994
object.put(LOGIN_SERVER, loginServer);
953995
object.put(ID_URL, idUrl);
954996
object.put(INSTANCE_SERVER, instanceServer);
@@ -1007,7 +1049,7 @@ public JSONObject toJson() {
10071049
Bundle toBundle(List<String> additionalOauthKeys) {
10081050
Bundle object = new Bundle();
10091051
object.putString(AUTH_TOKEN, authToken);
1010-
object.putString(REFRESH_TOKEN, refreshToken);
1052+
object.putString(REFRESH_TOKEN, getRefreshToken());
10111053
object.putString(LOGIN_SERVER, loginServer);
10121054
object.putString(ID_URL, idUrl);
10131055
object.putString(INSTANCE_SERVER, instanceServer);

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

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -442,7 +442,7 @@ public Bundle createAccount(UserAccount userAccount) {
442442
final Bundle extras = buildAuthBundle(userAccount);
443443

444444
Account acc = new Account(userAccount.getAccountName(), accountType);
445-
String password = SalesforceSDKManager.encrypt(userAccount.getRefreshToken(), encryptionKey);
445+
String password = SalesforceSDKManager.encrypt(userAccount.getRefreshTokenForPersistence(), encryptionKey);
446446
boolean success = accountManager.addAccountExplicitly(acc, password, new Bundle());
447447

448448
// Add account will fail if the account already exists, so update refresh token.
@@ -488,6 +488,16 @@ public Bundle updateAccount(Account account, UserAccount userAccount) {
488488
accountManager.setUserData(account, key, extras.getString(key));
489489
}
490490

491+
// The refresh token is stored as the Account's password (see createAccount), not as user data,
492+
// so buildAuthBundle does not include it. Persist it explicitly here so that server-side
493+
// Use the in-memory snapshot rather than getRefreshToken(), which now performs a live lookup
494+
// against AccountManager and would return the updated value we may be about to write.
495+
final String refreshToken = userAccount.getRefreshTokenForPersistence();
496+
if (refreshToken != null) {
497+
final String encryptionKey = SalesforceSDKManager.getEncryptionKey();
498+
accountManager.setPassword(account, SalesforceSDKManager.encrypt(refreshToken, encryptionKey));
499+
}
500+
491501
return extras;
492502
}
493503

libs/SalesforceSDK/src/com/salesforce/androidsdk/auth/AuthenticationUtilities.kt

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -465,8 +465,13 @@ internal fun handleDuplicateUserAccount(
465465
clearCaches()
466466
userAccountManager.clearCachedCurrentUser()
467467

468-
// Revoke existing refresh token
469-
if (account.refreshToken != duplicateUserAccount.refreshToken) {
468+
// Revoke existing refresh token. Compare the new account's in-memory
469+
// snapshot (the value just issued by the server) against the duplicate
470+
// account's snapshot (the value currently persisted). Using
471+
// getRefreshToken() here would do a live AccountManager lookup for
472+
// both UserAccounts and return the same persisted value, suppressing
473+
// the revocation and leaking the prior refresh token.
474+
if (account.refreshTokenForPersistence != duplicateUserAccount.refreshTokenForPersistence) {
470475
runCatching {
471476
URI(duplicateUserAccount.instanceServer)
472477
}.onFailure { throwable ->
@@ -481,7 +486,7 @@ internal fun handleDuplicateUserAccount(
481486
revokeRefreshToken(
482487
HttpAccess.DEFAULT,
483488
uri,
484-
duplicateUserAccount.refreshToken,
489+
duplicateUserAccount.refreshTokenForPersistence,
485490
OAuth2.LogoutReason.REFRESH_TOKEN_ROTATED,
486491
)
487492
}
@@ -515,7 +520,8 @@ private fun UserAccountManager.persistAccount(
515520
acctManager: AccountManager = AccountManager.get(SalesforceSDKManager.getInstance().appContext),
516521
) {
517522
val account = Account(userAccount.accountName, accountType)
518-
val password = SalesforceSDKManager.encrypt(userAccount.refreshToken, encryptionKey)
523+
// Encrypt the in-memory snapshot rather than getRefreshToken(), which performs a lookup.
524+
val password = SalesforceSDKManager.encrypt(userAccount.refreshTokenForPersistence, encryptionKey)
519525
val created = acctManager.addAccountExplicitly(account, password, /* userdata = */ Bundle())
520526

521527
// addAccountExplicitly fails if the account already exists, so update the refresh token.

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

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -354,7 +354,8 @@ public static class AccMgrAuthTokenProvider implements RestClient.AuthTokenProvi
354354
private final Object lock = new Object();
355355
private final ClientManager clientManager;
356356
private String lastNewAuthToken;
357-
private final String refreshToken;
357+
// Mutable to support server-side Refresh Token Rotation (RTR).
358+
private String refreshToken;
358359
private String lastNewInstanceUrl;
359360
private long lastRefreshTime = -1 /* never refreshed */;
360361

@@ -506,6 +507,12 @@ private UserAccount refreshStaleToken(Account account) throws NetworkErrorExcept
506507
updatedUserAccount.downloadProfilePhoto();
507508
UserAccountManager.getInstance().clearCachedCurrentUser();
508509

510+
// Handle server-side Refresh Token Rotation: if the response contained a new refresh token,
511+
// update this provider's cached copy.
512+
if (tr.refreshToken != null && !tr.refreshToken.equals(refreshToken)) {
513+
refreshToken = tr.refreshToken;
514+
}
515+
509516
return updatedUserAccount;
510517
} catch (OAuth2.OAuthFailedException ofe) {
511518
if (ofe.isRefreshTokenInvalid()) {

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

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,89 @@ public void testUserAccountToAccountToUserAccount() {
109109
checkSameUserAccount(userAccount, restoredUserAccount);
110110
}
111111

112+
/*
113+
* Server-side Refresh Token Rotation (RTR) regression test.
114+
*
115+
* The refresh token is persisted as the Account's password and is read back via accountManager.getPassword().
116+
* updateAccount must therefore persist a rotated refresh token via setPassword.
117+
*/
118+
@Test
119+
public void testUpdateAccountPersistsRotatedRefreshToken() {
120+
final UserAccount original = UserAccountTest.createTestAccount();
121+
userAccMgr.createAccount(original);
122+
final Account account = userAccMgr.getCurrentAccount();
123+
Assert.assertEquals(
124+
"Initial refresh token should round-trip through AccountManager",
125+
UserAccountTest.TEST_REFRESH_TOKEN,
126+
userAccMgr.buildUserAccount(account).getRefreshToken());
127+
128+
// Simulate a server-side refresh token rotation by building a
129+
// UserAccount with a new refresh token value and updating.
130+
final String rotatedRefreshToken = "rotated_refresh_token";
131+
final UserAccount rotated = UserAccountBuilder.getInstance()
132+
.populateFromUserAccount(original)
133+
.refreshToken(rotatedRefreshToken)
134+
.build();
135+
userAccMgr.updateAccount(account, rotated);
136+
137+
// The persisted refresh token must reflect the rotated value.
138+
final UserAccount reloaded = userAccMgr.buildUserAccount(account);
139+
Assert.assertEquals(
140+
"Rotated refresh token should be persisted by updateAccount",
141+
rotatedRefreshToken,
142+
reloaded.getRefreshToken());
143+
144+
// Encryption (AES-GCM with a random IV) is non-deterministic, so
145+
// compare against the decrypted password rather than re-encrypting
146+
// the expected value.
147+
final String encryptionKey = SalesforceSDKManager.getEncryptionKey();
148+
Assert.assertEquals(
149+
"Rotated refresh token should be used as the account password.",
150+
rotatedRefreshToken,
151+
SalesforceSDKManager.decrypt(accMgr.getPassword(account), encryptionKey));
152+
}
153+
154+
/*
155+
* Server-side Refresh Token Rotation (RTR) regression test.
156+
*
157+
* A UserAccount snapshot built from the persisted Account must reflect a
158+
* subsequent rotation of the refresh token via updateAccount, even when
159+
* the snapshot itself was built before the rotation. This guards the
160+
* live-lookup behavior of UserAccount.getRefreshToken().
161+
*/
162+
@Test
163+
public void testGetRefreshTokenReflectsLatestPersistedValue() {
164+
final UserAccount original = UserAccountTest.createTestAccount();
165+
userAccMgr.createAccount(original);
166+
final Account account = userAccMgr.getCurrentAccount();
167+
168+
// Snapshot taken before rotation.
169+
final UserAccount staleUser = userAccMgr.buildUserAccount(account);
170+
Assert.assertEquals(
171+
"Initial refresh token should be observed via the snapshot",
172+
UserAccountTest.TEST_REFRESH_TOKEN,
173+
staleUser.getRefreshToken());
174+
175+
// Rotate via updateAccount.
176+
final String rotatedRefreshToken = "rotated_refresh_token";
177+
final UserAccount rotated = UserAccountBuilder.getInstance()
178+
.populateFromUserAccount(original)
179+
.refreshToken(rotatedRefreshToken)
180+
.build();
181+
userAccMgr.updateAccount(account, rotated);
182+
183+
Assert.assertEquals(
184+
"Initial refresh token should be observed via the snapshot",
185+
UserAccountTest.TEST_REFRESH_TOKEN,
186+
staleUser.getRefreshTokenForPersistence());
187+
// The previously-built snapshot should now observe the rotated value
188+
// through its live lookup against AccountManager.
189+
Assert.assertEquals(
190+
"Snapshot built before rotation should reflect the latest persisted refresh token",
191+
rotatedRefreshToken,
192+
staleUser.getRefreshToken());
193+
}
194+
112195
/**
113196
* Test to get all authenticated users.
114197
*/

0 commit comments

Comments
 (0)