Skip to content

Commit cf5704e

Browse files
authored
Token Migration (#2837)
* Add token migration feature and AuthFlowTester UI to exercise it. * Improve Token Migration loading indicator. Add app icon for AuthFlowTester. * Cleanup Token Migration error logging. * Update existing test classes. * Add tests for UserAccountManager.migrateRefreshToken, TokenMigrationActivity and MigrationCallbackRegistry. * Add TokenMigrationActivity tests. * Additional tests. * Add tests for AuthenticationUtilities handleScreenLockPolicy, handleBiometricAuthPolicy and handleDuplicateUserAccount. Split out TokenMigrationView and add tests. * Remove unnecessary code and improve tests.
1 parent 1e647db commit cf5704e

38 files changed

Lines changed: 3283 additions & 216 deletions

.github/test-shards/SalesforceSDK.json

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
"shards": [
44
{
55
"name": "network",
6-
"comment": "REST client tests that make live server calls - isolated",
6+
"comment": "REST client tests that make live server calls",
77
"targets": [
88
"class com.salesforce.androidsdk.rest.RestClientTest",
99
"class com.salesforce.androidsdk.rest.ClientManagerTest",
@@ -14,7 +14,7 @@
1414
},
1515
{
1616
"name": "ui",
17-
"comment": "UI tests and security tests that may require user interaction",
17+
"comment": "Tests that exercise Activities/UI",
1818
"targets": [
1919
"class com.salesforce.androidsdk.ui.LoginViewActivityTest",
2020
"class com.salesforce.androidsdk.ui.PickerBottomSheetTest",
@@ -23,7 +23,9 @@
2323
"class com.salesforce.androidsdk.ui.PickerBottomSheetActivityTest",
2424
"class com.salesforce.androidsdk.ui.DevInfoActivityTest",
2525
"class com.salesforce.androidsdk.security.ScreenLockManagerTest",
26-
"class com.salesforce.androidsdk.security.BiometricAuthenticationManagerTest"
26+
"class com.salesforce.androidsdk.security.BiometricAuthenticationManagerTest",
27+
"class com.salesforce.androidsdk.ui.TokenMigrationActivityTest",
28+
"class com.salesforce.androidsdk.ui.TokenMigrationWebViewTest"
2729
]
2830
},
2931
{
@@ -68,7 +70,9 @@
6870
"notClass com.salesforce.androidsdk.rest.NotificationsActionsResponseBodyTest",
6971
"notClass com.salesforce.androidsdk.rest.files.RenditionTypeTest",
7072
"notClass com.salesforce.androidsdk.rest.files.ConnectUriBuilderTest",
71-
"notClass com.salesforce.androidsdk.rest.files.FileRequestsTest"
73+
"notClass com.salesforce.androidsdk.rest.files.FileRequestsTest",
74+
"notClass com.salesforce.androidsdk.ui.TokenMigrationActivityTest",
75+
"notClass com.salesforce.androidsdk.ui.TokenMigrationWebViewTest"
7276
]
7377
}
7478
]

.github/workflows/reusable-lib-workflow.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -184,7 +184,7 @@ jobs:
184184
fi
185185
done
186186
- name: Test Report
187-
uses: mikepenz/action-junit-report@v5
187+
uses: mikepenz/action-junit-report
188188
if: success() || failure()
189189
with:
190190
check_name: ${{ inputs.lib }} Test Results

.github/workflows/reusable-ui-workflow.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -127,7 +127,7 @@ jobs:
127127
fi
128128
done
129129
- name: Test Report
130-
uses: mikepenz/action-junit-report@v5
130+
uses: mikepenz/action-junit-report
131131
if: success() || failure()
132132
with:
133133
check_name: ${{ inputs.lib }} Test Results

libs/SalesforceSDK/AndroidManifest.xml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,12 @@
4545
android:configChanges="orientation|screenLayout|uiMode|screenSize|smallestScreenSize"
4646
android:exported="true" />
4747

48+
<!-- Token Migration Activity -->
49+
<activity android:name="com.salesforce.androidsdk.ui.TokenMigrationActivity"
50+
android:excludeFromRecents="true"
51+
android:theme="@style/AccountSwitcher"
52+
android:exported="false" />
53+
4854
<!-- Screen Lock Activity-->
4955
<activity android:name="com.salesforce.androidsdk.ui.ScreenLockActivity"
5056
android:exported="false"

libs/SalesforceSDK/build.gradle.kts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ plugins {
88
`publish-module`
99
jacoco
1010
kotlin("plugin.serialization") version "2.0.21"
11+
kotlin("plugin.parcelize")
1112
}
1213

1314
dependencies {
@@ -48,6 +49,7 @@ dependencies {
4849

4950
androidTestImplementation("androidx.test:runner:1.7.0")
5051
androidTestImplementation("androidx.test:rules:1.7.0")
52+
androidTestImplementation("androidx.test.espresso:espresso-core:3.7.0")
5153
androidTestImplementation("androidx.test.ext:junit:1.3.0")
5254
androidTestImplementation("androidx.arch.core:core-testing:2.2.0")
5355
androidTestImplementation("androidx.compose.ui:ui-test-junit4:$composeVersion")
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
/*
2+
* Copyright (c) 2026-present, salesforce.com, inc.
3+
* All rights reserved.
4+
* Redistribution and use of this software in source and binary forms, with or
5+
* without modification, are permitted provided that the following conditions
6+
* are met:
7+
* - Redistributions of source code must retain the above copyright notice, this
8+
* list of conditions and the following disclaimer.
9+
* - Redistributions in binary form must reproduce the above copyright notice,
10+
* this list of conditions and the following disclaimer in the documentation
11+
* and/or other materials provided with the distribution.
12+
* - Neither the name of salesforce.com, inc. nor the names of its contributors
13+
* may be used to endorse or promote products derived from this software without
14+
* specific prior written permission of salesforce.com, inc.
15+
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
16+
* AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
17+
* IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
18+
* ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE
19+
* LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
20+
* CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
21+
* SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
22+
* INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
23+
* CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
24+
* ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
25+
* POSSIBILITY OF SUCH DAMAGE.
26+
*/
27+
package com.salesforce.androidsdk.accounts
28+
29+
import android.content.Intent
30+
import com.salesforce.androidsdk.accounts.UserAccountManager.getInstance
31+
import com.salesforce.androidsdk.app.SalesforceSDKManager
32+
import com.salesforce.androidsdk.config.OAuthConfig
33+
import com.salesforce.androidsdk.ui.TokenMigrationActivity
34+
import com.salesforce.androidsdk.util.SalesforceSDKLogger
35+
import java.util.UUID
36+
37+
const val TAG = "UserAccountManager"
38+
39+
/**
40+
* Attempts to migrate the [userAccount] to the provided Connected App or
41+
* External Client Application [appConfig].
42+
*
43+
* This might cause the approve/deny screen to be presented to the user to authorize the
44+
* new app. If successful a new set of credentials (refresh token, access token) are obtained
45+
* and replace the existing credentials for the user.
46+
*/
47+
@Suppress("UnusedReceiverParameter")
48+
fun UserAccountManager.migrateRefreshToken(
49+
userAccount: UserAccount? = getInstance().currentUser,
50+
appConfig: OAuthConfig,
51+
onMigrationSuccess: (userAccount: UserAccount) -> Unit,
52+
onMigrationError: (error: String, errorDesc: String?, e: Throwable?) -> Unit,
53+
) {
54+
val loggedOnSuccess: (userAccount: UserAccount) -> Unit = { user ->
55+
SalesforceSDKLogger.i(TAG, "Token Migration Successful \n\nUser ${user.username} " +
56+
"(${user.instanceServer}) successfully migrated to: \n$appConfig.")
57+
onMigrationSuccess.invoke(user)
58+
}
59+
val userId = userAccount?.userId
60+
val orgId = userAccount?.orgId
61+
62+
if (userId == null || orgId == null) {
63+
val message = "User account, userId or orgId is null."
64+
SalesforceSDKLogger.e(TAG, message)
65+
onMigrationError(message, null, null)
66+
return
67+
}
68+
69+
val callbackKey = MigrationCallbackRegistry.register(
70+
callbacks = MigrationCallbackRegistry.MigrationCallbacks(
71+
onMigrationSuccess = loggedOnSuccess,
72+
onMigrationError = onMigrationError,
73+
)
74+
)
75+
76+
with(SalesforceSDKManager.getInstance().appContext) {
77+
startActivity(
78+
Intent(/* packageContext = */ this, TokenMigrationActivity::class.java).apply {
79+
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
80+
putExtra(TokenMigrationActivity.EXTRA_ORG_ID, orgId)
81+
putExtra(TokenMigrationActivity.EXTRA_USER_ID, userId)
82+
putExtra(TokenMigrationActivity.EXTRA_OAUTH_CONFIG, appConfig)
83+
putExtra(TokenMigrationActivity.EXTRA_CALLBACK_ID, callbackKey)
84+
}
85+
)
86+
}
87+
}
88+
89+
/*
90+
This mechanism is used to pass a _string_ id to the Activity to retrieve callback functions.
91+
92+
Lambda functions may appear Parcelable/Serializable but since we cannot guarantee the
93+
content are they should not be passed. For instance, if the lambda function contains
94+
compose state an exception will be thrown.
95+
*/
96+
internal object MigrationCallbackRegistry {
97+
private val callbacks = mutableMapOf<String, MigrationCallbacks>()
98+
99+
data class MigrationCallbacks(
100+
val onMigrationSuccess: (UserAccount) -> Unit,
101+
val onMigrationError: (String, String?, Throwable?) -> Unit
102+
)
103+
104+
fun register(callbacks: MigrationCallbacks): String {
105+
val key = UUID.randomUUID().toString()
106+
this.callbacks[key] = callbacks
107+
return key
108+
}
109+
110+
fun consume(key: String): MigrationCallbacks? = callbacks.remove(key)
111+
}

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

Lines changed: 42 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,6 @@ import com.salesforce.androidsdk.app.SalesforceSDKManager
4747
import com.salesforce.androidsdk.auth.OAuth2.TokenEndpointResponse
4848
import com.salesforce.androidsdk.auth.OAuth2.addAuthorizationHeader
4949
import com.salesforce.androidsdk.auth.OAuth2.callIdentityService
50-
import com.salesforce.androidsdk.auth.OAuth2.revokeRefreshToken
5150
import com.salesforce.androidsdk.config.LoginServerManager
5251
import com.salesforce.androidsdk.config.RuntimeConfig
5352
import com.salesforce.androidsdk.config.RuntimeConfig.getRuntimeConfig
@@ -98,6 +97,7 @@ internal suspend fun onAuthFlowComplete(
9897
onAuthFlowSuccess: (userAccount: UserAccount) -> Unit,
9998
buildAccountName: (username: String?, instanceServer: String?) -> String = ::defaultBuildAccountName,
10099
nativeLogin: Boolean = false,
100+
tokenMigration: Boolean = false,
101101
context: Context = SalesforceSDKManager.getInstance().appContext,
102102
userAccountManager: UserAccountManager = SalesforceSDKManager.getInstance().userAccountManager,
103103
blockIntegrationUser: Boolean = (SalesforceSDKManager.getInstance().shouldBlockSalesforceIntegrationUser &&
@@ -110,7 +110,8 @@ internal suspend fun onAuthFlowComplete(
110110
addAccount: (account: UserAccount) -> Unit = ::addAccountHelper,
111111
handleScreenLockPolicy: (userIdentity: OAuth2.IdServiceResponse?, account: UserAccount) -> Unit = ::handleScreenLockPolicy,
112112
handleBiometricAuthPolicy: (userIdentity: OAuth2.IdServiceResponse?, account: UserAccount) -> Unit = ::handleBiometricAuthPolicy,
113-
handleDuplicateUserAccount: (userAccountManager: UserAccountManager, account: UserAccount, userIdentity: OAuth2.IdServiceResponse?) -> Unit = ::handleDuplicateUserAccount,
113+
handleDuplicateUserAccount: (userAccountManager: UserAccountManager, account: UserAccount, userIdentity: OAuth2.IdServiceResponse?) -> Unit
114+
= { uam, acct, identity -> com.salesforce.androidsdk.auth.handleDuplicateUserAccount(uam, acct, identity) },
114115
) {
115116
// Reset Dev Support LoginOptionsActivity override
116117
SalesforceSDKManager.getInstance().debugOverrideAppConfig = null
@@ -189,9 +190,11 @@ internal suspend fun onAuthFlowComplete(
189190
}
190191
userAccountManager.sendUserSwitchIntent(userSwitchType, null)
191192

192-
// Kickoff the end of the flow before storing mobile policy to prevent launching
193-
// the main activity over/after the screen lock.
194-
startMainActivity()
193+
if (!tokenMigration) {
194+
// Kickoff the end of the flow before storing mobile policy to prevent launching
195+
// the main activity over/after the screen lock.
196+
startMainActivity()
197+
}
195198

196199
// Let the calling process resume
197200
onAuthFlowSuccess(account)
@@ -371,36 +374,51 @@ private fun updateLoggingPrefsHelper(account: UserAccount) {
371374
/**
372375
* Helper method to handle screen lock mobile policy.
373376
*/
374-
private fun handleScreenLockPolicy(
377+
@VisibleForTesting
378+
internal fun handleScreenLockPolicy(
375379
userIdentity: OAuth2.IdServiceResponse?,
376-
account: UserAccount
380+
account: UserAccount,
377381
) {
382+
val internalScreenLockManager =
383+
SalesforceSDKManager.getInstance().screenLockManager as ScreenLockManager?
384+
385+
// compareTo(0) is used to check if screenLockTimeout is non-null and greater than 0.
378386
if (userIdentity?.screenLockTimeout?.compareTo(0) == 1) {
379387
SalesforceSDKManager.getInstance().registerUsedAppFeature(FEATURE_SCREEN_LOCK)
380388
val timeoutInMills = userIdentity.screenLockTimeout * 1000 * 60
381-
(SalesforceSDKManager.getInstance().screenLockManager as ScreenLockManager?)?.storeMobilePolicy(
389+
internalScreenLockManager?.storeMobilePolicy(
382390
account,
383-
userIdentity.screenLock,
384-
timeoutInMills
391+
enabled = userIdentity.screenLock,
392+
timeoutInMills,
385393
)
394+
} else if (internalScreenLockManager?.enabled == true) {
395+
SalesforceSDKManager.getInstance().unregisterUsedAppFeature(FEATURE_SCREEN_LOCK)
396+
internalScreenLockManager.cleanUp(account)
386397
}
387398
}
388399

389400
/**
390401
* Helper method to handle biometric authentication mobile policy.
391402
*/
392-
private fun handleBiometricAuthPolicy(
403+
@VisibleForTesting
404+
internal fun handleBiometricAuthPolicy(
393405
userIdentity: OAuth2.IdServiceResponse?,
394-
account: UserAccount
406+
account: UserAccount,
395407
) {
408+
val internalBiometricAuthenticationManager =
409+
SalesforceSDKManager.getInstance().biometricAuthenticationManager as BiometricAuthenticationManager?
410+
396411
if (userIdentity?.biometricAuth == true) {
397412
SalesforceSDKManager.getInstance().registerUsedAppFeature(FEATURE_BIOMETRIC_AUTH)
398413
val timeoutInMills = userIdentity.biometricAuthTimeout * 60 * 1000
399-
(SalesforceSDKManager.getInstance().biometricAuthenticationManager as BiometricAuthenticationManager?)?.storeMobilePolicy(
414+
internalBiometricAuthenticationManager?.storeMobilePolicy(
400415
account,
401-
userIdentity.biometricAuth,
416+
enabled = userIdentity.biometricAuth,
402417
timeoutInMills
403418
)
419+
} else if (internalBiometricAuthenticationManager?.enabled == true) {
420+
SalesforceSDKManager.getInstance().unregisterUsedAppFeature(FEATURE_BIOMETRIC_AUTH)
421+
internalBiometricAuthenticationManager.cleanUp(account)
404422
}
405423
}
406424

@@ -426,10 +444,12 @@ private fun addAccountHelper(
426444
* - Unlocking biometric authentication for the duplicate user
427445
* - Signing out other users with biometric auth when a new biometric user is added
428446
*/
429-
private fun handleDuplicateUserAccount(
447+
@VisibleForTesting
448+
internal fun handleDuplicateUserAccount(
430449
userAccountManager: UserAccountManager,
431450
account: UserAccount,
432-
userIdentity: OAuth2.IdServiceResponse?
451+
userIdentity: OAuth2.IdServiceResponse?,
452+
revokeRefreshToken: (HttpAccess, URI, String, OAuth2.LogoutReason) -> Unit = OAuth2::revokeRefreshToken,
433453
) {
434454
userAccountManager.authenticatedUsers?.let { existingUsers ->
435455
// Check if the user already exists
@@ -451,14 +471,12 @@ private fun handleDuplicateUserAccount(
451471
as? BiometricAuthenticationManager)?.onUnlock()
452472
}
453473
CoroutineScope(IO).launch {
454-
CoroutineScope(IO).launch {
455-
revokeRefreshToken(
456-
HttpAccess.DEFAULT,
457-
uri,
458-
duplicateUserAccount.refreshToken,
459-
OAuth2.LogoutReason.REFRESH_TOKEN_ROTATED,
460-
)
461-
}
474+
revokeRefreshToken(
475+
HttpAccess.DEFAULT,
476+
uri,
477+
duplicateUserAccount.refreshToken,
478+
OAuth2.LogoutReason.REFRESH_TOKEN_ROTATED,
479+
)
462480
}
463481
}
464482
}

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,7 @@ public class OAuth2 {
133133
private static final String QUESTION = "?";
134134
private static final String TOUCH = "touch";
135135
private static final String FRONTDOOR = "/secur/frontdoor.jsp?";
136+
public static final String FRONTDOOR_URL_KEY = "frontdoor_uri";
136137
private static final String SID = "sid";
137138
private static final String RETURL = "retURL";
138139
protected static final String AUTHORIZATION = "Authorization";

libs/SalesforceSDK/src/com/salesforce/androidsdk/auth/idp/IDPAuthCodeHelper.kt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ import android.webkit.WebViewClient
3333
import com.salesforce.androidsdk.R
3434
import com.salesforce.androidsdk.accounts.UserAccount
3535
import com.salesforce.androidsdk.app.SalesforceSDKManager
36+
import com.salesforce.androidsdk.auth.OAuth2.FRONTDOOR_URL_KEY
3637
import com.salesforce.androidsdk.auth.OAuth2.getAuthorizationUrl
3738
import com.salesforce.androidsdk.rest.ClientManager
3839
import com.salesforce.androidsdk.rest.RestClient
@@ -132,7 +133,7 @@ internal class IDPAuthCodeHelper private constructor(
132133
SalesforceSDKLogger.e(TAG, "Failed to obtain valid front door url", e)
133134
null
134135
}
135-
return if (restResponse == null || !restResponse.isSuccess) null else restResponse.asJSONObject().getString("frontdoor_uri")
136+
return if (restResponse == null || !restResponse.isSuccess) null else restResponse.asJSONObject().getString(FRONTDOOR_URL_KEY)
136137
}
137138

138139
private fun onError(error: String, exception: java.lang.Exception? = null) {

libs/SalesforceSDK/src/com/salesforce/androidsdk/config/OAuthConfig.kt

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,11 +26,15 @@
2626
*/
2727
package com.salesforce.androidsdk.config
2828

29+
import android.os.Parcelable
30+
import kotlinx.parcelize.Parcelize
31+
32+
@Parcelize
2933
data class OAuthConfig(
3034
val consumerKey: String,
3135
val redirectUri: String,
3236
val scopes: List<String>? = null,
33-
) {
37+
): Parcelable {
3438

3539
internal constructor(bootConfig: BootConfig): this(
3640
bootConfig.remoteAccessConsumerKey,

0 commit comments

Comments
 (0)