Skip to content

Commit 5fa7cf7

Browse files
committed
Add token migration feature and AuthFlowTester UI to exercise it.
1 parent 893ccd8 commit 5fa7cf7

17 files changed

Lines changed: 640 additions & 59 deletions

File tree

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: 1 addition & 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 {
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.app.SalesforceSDKManager
31+
import com.salesforce.androidsdk.config.OAuthConfig
32+
import com.salesforce.androidsdk.ui.TokenMigrationActivity
33+
import com.salesforce.androidsdk.util.SalesforceSDKLogger
34+
import java.util.UUID
35+
36+
const val TAG = "UserAccountManager"
37+
38+
/**
39+
* Attempts to migrate the [userAccount] to the provided Connected App or
40+
* External Client Application [appConfig].
41+
*
42+
* This might cause the approve/deny screen to be presented to the user to authorize the
43+
* new app. If successful a new set of credentials (refresh token, access token) are obtained
44+
* and replace the existing credentials for the user.
45+
*/
46+
fun UserAccountManager.migrateRefreshToken(
47+
userAccount: UserAccount? = UserAccountManager.getInstance().currentUser,
48+
appConfig: OAuthConfig,
49+
onMigrationSuccess: (userAccount: UserAccount) -> Unit,
50+
onMigrationError: (error: String, errorDesc: String?, e: Throwable?) -> Unit,
51+
) {
52+
val loggedOnSuccess: (userAccount: UserAccount) -> Unit = { user ->
53+
SalesforceSDKLogger.i(TAG, "User ($user) successfully migrated to " +
54+
"new OAuthConfig ($appConfig).")
55+
onMigrationSuccess.invoke(user)
56+
}
57+
val loggedOnError: (error: String, errorDesc: String?, e: Throwable?) -> Unit = { error, errorDesc, e ->
58+
val message = error + errorDesc?.let { "\nDescription: $it" }
59+
SalesforceSDKLogger.e(TAG, message, e)
60+
onMigrationError.invoke(error, errorDesc, e)
61+
}
62+
63+
val userId = userAccount?.userId ?: run {
64+
loggedOnError("User account or userId is null.", null, null)
65+
return
66+
}
67+
val orgId = userAccount.orgId ?: run {
68+
loggedOnError("OrgId is null.", null, null)
69+
return
70+
}
71+
72+
val callbackKey = MigrationCallbackRegistry.register(
73+
callbacks = MigrationCallbackRegistry.MigrationCallbacks(
74+
onMigrationSuccess = loggedOnSuccess,
75+
onMigrationError = loggedOnError,
76+
)
77+
)
78+
79+
val context = SalesforceSDKManager.getInstance().appContext
80+
val intent = Intent(context, TokenMigrationActivity::class.java)
81+
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
82+
intent.putExtra(TokenMigrationActivity.EXTRA_ORG_ID, orgId)
83+
intent.putExtra(TokenMigrationActivity.EXTRA_USER_ID, userId)
84+
intent.putExtra(TokenMigrationActivity.EXTRA_OAUTH_CONFIG, appConfig)
85+
intent.putExtra(TokenMigrationActivity.EXTRA_CALLBACK_ID, callbackKey)
86+
context.startActivity(intent)
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: 30 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,7 @@ internal suspend fun onAuthFlowComplete(
9898
onAuthFlowSuccess: (userAccount: UserAccount) -> Unit,
9999
buildAccountName: (username: String?, instanceServer: String?) -> String = ::defaultBuildAccountName,
100100
nativeLogin: Boolean = false,
101+
tokenMigration: Boolean = false,
101102
context: Context = SalesforceSDKManager.getInstance().appContext,
102103
userAccountManager: UserAccountManager = SalesforceSDKManager.getInstance().userAccountManager,
103104
blockIntegrationUser: Boolean = (SalesforceSDKManager.getInstance().shouldBlockSalesforceIntegrationUser &&
@@ -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)
@@ -375,14 +378,21 @@ private fun handleScreenLockPolicy(
375378
userIdentity: OAuth2.IdServiceResponse?,
376379
account: UserAccount
377380
) {
381+
val internalScreenLockManager =
382+
SalesforceSDKManager.getInstance().screenLockManager as ScreenLockManager?
383+
384+
// compareTo(0) is used to check if screenLockTimeout is non-null and greater than 0.
378385
if (userIdentity?.screenLockTimeout?.compareTo(0) == 1) {
379386
SalesforceSDKManager.getInstance().registerUsedAppFeature(FEATURE_SCREEN_LOCK)
380387
val timeoutInMills = userIdentity.screenLockTimeout * 1000 * 60
381-
(SalesforceSDKManager.getInstance().screenLockManager as ScreenLockManager?)?.storeMobilePolicy(
388+
internalScreenLockManager?.storeMobilePolicy(
382389
account,
383-
userIdentity.screenLock,
384-
timeoutInMills
390+
enabled = userIdentity.screenLock,
391+
timeoutInMills,
385392
)
393+
} else if (internalScreenLockManager?.enabled == true) {
394+
SalesforceSDKManager.getInstance().unregisterUsedAppFeature(FEATURE_SCREEN_LOCK)
395+
internalScreenLockManager.cleanUp(account)
386396
}
387397
}
388398

@@ -393,14 +403,20 @@ private fun handleBiometricAuthPolicy(
393403
userIdentity: OAuth2.IdServiceResponse?,
394404
account: UserAccount
395405
) {
406+
val internalBiometricAuthenticationManager =
407+
SalesforceSDKManager.getInstance().biometricAuthenticationManager as BiometricAuthenticationManager?
408+
396409
if (userIdentity?.biometricAuth == true) {
397410
SalesforceSDKManager.getInstance().registerUsedAppFeature(FEATURE_BIOMETRIC_AUTH)
398411
val timeoutInMills = userIdentity.biometricAuthTimeout * 60 * 1000
399-
(SalesforceSDKManager.getInstance().biometricAuthenticationManager as BiometricAuthenticationManager?)?.storeMobilePolicy(
412+
internalBiometricAuthenticationManager?.storeMobilePolicy(
400413
account,
401-
userIdentity.biometricAuth,
414+
enabled = userIdentity.biometricAuth,
402415
timeoutInMills
403416
)
417+
} else if (internalBiometricAuthenticationManager?.enabled == true) {
418+
SalesforceSDKManager.getInstance().unregisterUsedAppFeature(FEATURE_BIOMETRIC_AUTH)
419+
internalBiometricAuthenticationManager.cleanUp(account)
404420
}
405421
}
406422

@@ -438,6 +454,12 @@ private fun handleDuplicateUserAccount(
438454
clearCaches()
439455
userAccountManager.clearCachedCurrentUser()
440456

457+
// Remove the existing Account from AccountManager so createAccount can create a fresh one
458+
val existingAccount = userAccountManager.buildAccount(duplicateUserAccount)
459+
if (existingAccount != null) {
460+
SalesforceSDKManager.getInstance().clientManager.removeAccount(existingAccount)
461+
}
462+
441463
// Revoke existing refresh token
442464
if (account.refreshToken != duplicateUserAccount.refreshToken) {
443465
runCatching {

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,

libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/LoginActivity.kt

Lines changed: 15 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -239,6 +239,7 @@ open class LoginActivity : FragmentActivity() {
239239
newUserIntent = true
240240
}
241241

242+
// TODO: Move to non-deprecated getParcelableExtra when min API >= 33
242243
accountAuthenticatorResponse = intent.getParcelableExtra<AccountAuthenticatorResponse?>(
243244
KEY_ACCOUNT_AUTHENTICATOR_RESPONSE
244245
)?.apply {
@@ -1217,18 +1218,6 @@ open class LoginActivity : FragmentActivity() {
12171218
d(TAG, "Received client certificate request from server")
12181219
request.proceed(key, certChain)
12191220
}
1220-
1221-
private fun validateAndExtractBackgroundColor(javaScriptResult: String): Color? {
1222-
val rgbMatch = rgbTextPattern.find(javaScriptResult)
1223-
1224-
// groupValues[0] is the entire match. [1] is red, [2] is green, [3] is green.
1225-
rgbMatch?.groupValues?.get(3) ?: return null
1226-
val red = rgbMatch.groupValues[1].toIntOrNull() ?: return null
1227-
val green = rgbMatch.groupValues[2].toIntOrNull() ?: return null
1228-
val blue = rgbMatch.groupValues[3].toIntOrNull() ?: return null
1229-
1230-
return Color(red, green, blue)
1231-
}
12321221
}
12331222

12341223
companion object {
@@ -1245,15 +1234,27 @@ open class LoginActivity : FragmentActivity() {
12451234
private const val RESPONSE_ERROR_DESCRIPTION_INTENT = "com.salesforce.auth.intent.RESPONSE_ERROR_DESCRIPTION"
12461235

12471236
// This parses the expected "rgb(x, x, x)" string.
1248-
private val rgbTextPattern = "rgb\\((\\d{1,3}), (\\d{1,3}), (\\d{1,3})\\)".toRegex()
1237+
internal val rgbTextPattern = "rgb\\((\\d{1,3}), (\\d{1,3}), (\\d{1,3})\\)".toRegex()
12491238

12501239
// endregion
12511240
// region LoginWebviewClient Constants
12521241

12531242
internal const val ABOUT_BLANK = "about:blank"
1254-
private const val BACKGROUND_COLOR_JAVASCRIPT =
1243+
internal const val BACKGROUND_COLOR_JAVASCRIPT =
12551244
"(function() { return window.getComputedStyle(document.body, null).getPropertyValue('background-color'); })();"
12561245

1246+
internal fun validateAndExtractBackgroundColor(javaScriptResult: String): Color? {
1247+
val rgbMatch = rgbTextPattern.find(javaScriptResult)
1248+
1249+
// groupValues[0] is the entire match. [1] is red, [2] is green, [3] is green.
1250+
rgbMatch?.groupValues?.get(3) ?: return null
1251+
val red = rgbMatch.groupValues[1].toIntOrNull() ?: return null
1252+
val green = rgbMatch.groupValues[2].toIntOrNull() ?: return null
1253+
val blue = rgbMatch.groupValues[3].toIntOrNull() ?: return null
1254+
1255+
return Color(red, green, blue)
1256+
}
1257+
12571258
// endregion
12581259
// region Log In Via Salesforce Identity API UI Bridge Front Door URL Public Implementation
12591260

0 commit comments

Comments
 (0)