@@ -29,9 +29,13 @@ package com.salesforce.androidsdk.accounts
2929import android.content.Intent
3030import com.salesforce.androidsdk.accounts.UserAccountManager.getInstance
3131import com.salesforce.androidsdk.app.SalesforceSDKManager
32+ import com.salesforce.androidsdk.auth.ScopeParser.Companion.toScopeParser
3233import com.salesforce.androidsdk.config.OAuthConfig
3334import com.salesforce.androidsdk.ui.TokenMigrationActivity
3435import com.salesforce.androidsdk.util.SalesforceSDKLogger
36+ import kotlinx.coroutines.CoroutineScope
37+ import kotlinx.coroutines.Dispatchers.Default
38+ import kotlinx.coroutines.launch
3539import java.util.UUID
3640
3741const val TAG = " UserAccountManager"
@@ -43,11 +47,43 @@ const val TAG = "UserAccountManager"
4347 * This might cause the approve/deny screen to be presented to the user to authorize the
4448 * new app. If successful a new set of credentials (refresh token, access token) are obtained
4549 * and replace the existing credentials for the user.
50+ *
51+ * This overload preserves the original (pre-DPoP) behavior: the migrated session defers to the
52+ * global [SalesforceSDKManager.useDPoP] flag for its DPoP posture. To express a per-call DPoP
53+ * intent, use the [useDPoP]-carrying overload; for the common same-config, DPoP-upgrade case,
54+ * see [upgradeToDPoP].
55+ */
56+ fun UserAccountManager.migrateRefreshToken (
57+ userAccount : UserAccount ? = getInstance().currentUser,
58+ appConfig : OAuthConfig ,
59+ onMigrationSuccess : (userAccount: UserAccount ) -> Unit ,
60+ onMigrationError : (error: String , errorDesc: String? , e: Throwable ? ) -> Unit ,
61+ ) = migrateRefreshToken(
62+ userAccount = userAccount,
63+ appConfig = appConfig,
64+ useDPoP = null ,
65+ onMigrationSuccess = onMigrationSuccess,
66+ onMigrationError = onMigrationError,
67+ )
68+
69+ /* *
70+ * Attempts to migrate the [userAccount] to the provided Connected App or
71+ * External Client Application [appConfig], with an explicit per-call DPoP intent.
72+ *
73+ * This might cause the approve/deny screen to be presented to the user to authorize the
74+ * new app. If successful a new set of credentials (refresh token, access token) are obtained
75+ * and replace the existing credentials for the user.
76+ *
77+ * [useDPoP] expresses the DPoP intent for this specific migration call: `true` binds the
78+ * migrated session to DPoP, `false` migrates it unbound, and `null` defers to the global
79+ * [SalesforceSDKManager.useDPoP] flag (the behavior of the overload without this parameter).
80+ * See [upgradeToDPoP] for the common same-config, `useDPoP = true` case.
4681 */
4782@Suppress(" UnusedReceiverParameter" )
4883fun UserAccountManager.migrateRefreshToken (
4984 userAccount : UserAccount ? = getInstance().currentUser,
5085 appConfig : OAuthConfig ,
86+ useDPoP : Boolean? ,
5187 onMigrationSuccess : (userAccount: UserAccount ) -> Unit ,
5288 onMigrationError : (error: String , errorDesc: String? , e: Throwable ? ) -> Unit ,
5389) {
@@ -81,11 +117,94 @@ fun UserAccountManager.migrateRefreshToken(
81117 putExtra(TokenMigrationActivity .EXTRA_USER_ID , userId)
82118 putExtra(TokenMigrationActivity .EXTRA_OAUTH_CONFIG , appConfig)
83119 putExtra(TokenMigrationActivity .EXTRA_CALLBACK_ID , callbackKey)
120+ // Only carry a per-call DPoP intent when the caller expressed one; omitting the
121+ // extra lets TokenMigrationActivity defer to the global SalesforceSDKManager.useDPoP
122+ // flag (prior behavior).
123+ useDPoP?.let { putExtra(TokenMigrationActivity .EXTRA_USE_DPOP , it) }
84124 }
85125 )
86126 }
87127}
88128
129+ /* *
130+ * Upgrades the [userAccount]'s existing Bearer (non-DPoP) refresh token to a DPoP-bound one,
131+ * in place — same consumer key, redirect URI, and scopes the account already uses. This is a
132+ * same-config convenience over [migrateRefreshToken] with `useDPoP = true`: no re-consent is
133+ * expected because nothing about the connected app / External Client App configuration changes.
134+ *
135+ * The redirect URI used is the one persisted on [userAccount] at login time (the exact value
136+ * the connected app / External Client App was configured with for this user); it only falls
137+ * back to resolving the OAuth configuration for the account's login server for accounts that
138+ * were persisted before the redirect URI was captured on [UserAccount].
139+ *
140+ * This works regardless of the global [SalesforceSDKManager.useDPoP] flag: that flag only sets
141+ * the default DPoP posture for brand-new logins, while this call is an explicit action on an
142+ * already-authenticated session. Callers wanting to migrate to a *different* consumer key,
143+ * redirect URI, or scopes (or to explicitly downgrade a DPoP-bound session back to Bearer)
144+ * should call [migrateRefreshToken] directly with their own [OAuthConfig] and `useDPoP` value.
145+ *
146+ * Note: [onFailure] (and [onSuccess]) may be invoked off the main thread — the synchronous
147+ * null-check failure below runs on the caller's thread, but the OAuth-config resolution and
148+ * migration below it run on [Default]. Callers that touch UI from these callbacks must marshal
149+ * to the main thread themselves.
150+ */
151+ @Suppress(" UnusedReceiverParameter" )
152+ fun UserAccountManager.upgradeToDPoP (
153+ userAccount : UserAccount ,
154+ onSuccess : (userAccount: UserAccount ) -> Unit ,
155+ onFailure : (error: String , errorDesc: String? , e: Throwable ? ) -> Unit ,
156+ ) {
157+ val clientId = userAccount.clientId
158+ val loginServer = userAccount.loginServer
159+
160+ if (clientId == null || loginServer == null ) {
161+ val message = " User account clientId or loginServer is null."
162+ SalesforceSDKLogger .e(TAG , message)
163+ onFailure(message, null , null )
164+ return
165+ }
166+
167+ // Prefer the redirect URI persisted on the account at login time: it's the exact value the
168+ // connected app / External Client App was configured with for this user, and it doesn't
169+ // change over time. Only fall back to resolving the OAuth configuration for the user's login
170+ // server (debug override, per-host app config, or boot config) for accounts persisted before
171+ // redirect URI was captured on UserAccount. Either way, keep the user's own consumer key and
172+ // scopes so the upgrade is a true same-config, in-place operation.
173+ CoroutineScope (Default ).launch {
174+ runCatching {
175+ val persistedRedirectUri = userAccount.redirectUri
176+ val redirectUri = if (! persistedRedirectUri.isNullOrBlank()) {
177+ persistedRedirectUri
178+ } else {
179+ SalesforceSDKManager .getInstance()
180+ .resolveOAuthConfigForLoginServer(loginServer)
181+ .redirectUri
182+ }
183+
184+ OAuthConfig (
185+ consumerKey = clientId,
186+ redirectUri = redirectUri,
187+ scopes = userAccount.scope?.toScopeParser()?.scopes?.toList(),
188+ )
189+ }.fold(
190+ onSuccess = { appConfig ->
191+ migrateRefreshToken(
192+ userAccount = userAccount,
193+ appConfig = appConfig,
194+ useDPoP = true ,
195+ onMigrationSuccess = onSuccess,
196+ onMigrationError = onFailure,
197+ )
198+ },
199+ onFailure = { e ->
200+ val message = " Failed to resolve OAuth configuration for login server."
201+ SalesforceSDKLogger .e(TAG , message, e)
202+ onFailure(message, e.message, e)
203+ },
204+ )
205+ }
206+ }
207+
89208/*
90209 This mechanism is used to pass a _string_ id to the Activity to retrieve callback functions.
91210
0 commit comments