Skip to content

Commit 68a23bf

Browse files
authored
Merge pull request forcedotcom#2995 from sfdctaka/feature/dpop-upgrade-api-and-tests
In-place upgrade to DPoP + per-call DPoP intent on token migration
2 parents 638fbea + aecdf7e commit 68a23bf

18 files changed

Lines changed: 654 additions & 4 deletions

File tree

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

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,7 @@ public class UserAccount {
9999
public static final String COOKIE_SID_CLIENT = "cookie-sid_Client";
100100
public static final String SID_COOKIE_NAME = "sidCookieName";
101101
public static final String CLIENT_ID = "clientId";
102+
public static final String REDIRECT_URI = "redirectUri";
102103
public static final String PARENT_SID = "parentSid";
103104
public static final String TOKEN_FORMAT = "tokenFormat";
104105
public static final String BEACON_CHILD_CONSUMER_KEY = "auto_installed_app_org_consumer_key";
@@ -149,6 +150,7 @@ public class UserAccount {
149150
private String cookieSidClient;
150151
private String sidCookieName;
151152
private String clientId;
153+
private String redirectUri;
152154
private String parentSid;
153155
private String tokenFormat;
154156
private Map<String, String> additionalOauthValues;
@@ -194,6 +196,7 @@ public class UserAccount {
194196
* @param cookieSidClient cookie sid client
195197
* @param sidCookieName sid cookie name
196198
* @param clientId oauth client id
199+
* @param redirectUri oauth redirect uri
197200
* @param parentSid parent sid
198201
* @param tokenFormat token format
199202
* @param beaconChildConsumerKey beacon child consumer key
@@ -210,7 +213,7 @@ public class UserAccount {
210213
String lightningDomain, String lightningSid, String vfDomain, String vfSid,
211214
String contentDomain, String contentSid, String csrfToken, Boolean nativeLogin,
212215
String language, String locale, String cookieClientSrc, String cookieSidClient,
213-
String sidCookieName, String clientId, String parentSid, String tokenFormat,
216+
String sidCookieName, String clientId, String redirectUri, String parentSid, String tokenFormat,
214217
String beaconChildConsumerKey, String beaconChildConsumerSecret, String apiInstanceServer, String scope) {
215218
this.authToken = authToken;
216219
this.refreshToken = refreshToken;
@@ -245,6 +248,7 @@ public class UserAccount {
245248
this.cookieSidClient = cookieSidClient;
246249
this.sidCookieName = sidCookieName;
247250
this.clientId = clientId;
251+
this.redirectUri = redirectUri;
248252
this.parentSid = parentSid;
249253
this.tokenFormat = tokenFormat;
250254
this.beaconChildConsumerKey = beaconChildConsumerKey;
@@ -296,6 +300,7 @@ public class UserAccount {
296300
cookieSidClient = object.optString(COOKIE_SID_CLIENT, null);
297301
sidCookieName = object.optString(SID_COOKIE_NAME, null);
298302
clientId = object.optString(CLIENT_ID, null);
303+
redirectUri = object.optString(REDIRECT_URI, null);
299304
parentSid = object.optString(PARENT_SID, null);
300305
tokenFormat = object.optString(TOKEN_FORMAT, null);
301306
beaconChildConsumerKey = object.optString(BEACON_CHILD_CONSUMER_KEY, null);
@@ -356,6 +361,7 @@ public UserAccount(JSONObject object) {
356361
cookieSidClient = bundle.getString(COOKIE_SID_CLIENT);
357362
sidCookieName = bundle.getString(SID_COOKIE_NAME);
358363
clientId = bundle.getString(CLIENT_ID);
364+
redirectUri = bundle.getString(REDIRECT_URI);
359365
parentSid = bundle.getString(PARENT_SID);
360366
tokenFormat = bundle.getString(TOKEN_FORMAT);
361367
beaconChildConsumerKey = bundle.getString(BEACON_CHILD_CONSUMER_KEY);
@@ -706,6 +712,15 @@ public String getClientId() {
706712
return clientId;
707713
}
708714

715+
/**
716+
* Returns the oauth redirect uri.
717+
*
718+
* @return redirect uri.
719+
*/
720+
public String getRedirectUri() {
721+
return redirectUri;
722+
}
723+
709724
/**
710725
* Returns the oauth client id to use for refresh
711726
* In the case of beacon app, the beacon child consumer key returned during login should be used instead of the configured consumer key
@@ -1116,6 +1131,7 @@ JSONObject toJson(List<String> additionalOauthKeys) {
11161131
object.put(COOKIE_CLIENT_SRC, cookieClientSrc);
11171132
object.put(COOKIE_SID_CLIENT, cookieSidClient);
11181133
object.put(SID_COOKIE_NAME, sidCookieName);
1134+
object.put(REDIRECT_URI, redirectUri);
11191135
object.put(PARENT_SID, parentSid);
11201136
object.put(TOKEN_FORMAT, tokenFormat);
11211137
object.put(BEACON_CHILD_CONSUMER_KEY, beaconChildConsumerKey);
@@ -1185,6 +1201,7 @@ Bundle toBundle(List<String> additionalOauthKeys) {
11851201
object.putString(COOKIE_SID_CLIENT, cookieSidClient);
11861202
object.putString(SID_COOKIE_NAME, sidCookieName);
11871203
object.putString(CLIENT_ID, clientId);
1204+
object.putString(REDIRECT_URI, redirectUri);
11881205
object.putString(PARENT_SID, parentSid);
11891206
object.putString(TOKEN_FORMAT, tokenFormat);
11901207
object.putString(BEACON_CHILD_CONSUMER_KEY, beaconChildConsumerKey);

libs/SalesforceSDK/src/com/salesforce/androidsdk/accounts/UserAccountBuilder.kt

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ class UserAccountBuilder private constructor() {
6565
private var cookieSidClient: String? = null
6666
private var sidCookieName: String? = null
6767
private var clientId: String? = null
68+
private var redirectUri: String? = null
6869
private var parentSid: String? = null
6970
private var tokenFormat: String? = null
7071
private var additionalOauthValues: Map<String, String>? = null
@@ -175,6 +176,7 @@ class UserAccountBuilder private constructor() {
175176
.cookieSidClient(userAccount.cookieSidClient)
176177
.sidCookieName(userAccount.sidCookieName)
177178
.clientId(userAccount.clientId)
179+
.redirectUri(userAccount.redirectUri)
178180
.parentSid(userAccount.parentSid)
179181
.tokenFormat(userAccount.tokenFormat)
180182
.beaconChildConsumerKey(userAccount.beaconChildConsumerKey)
@@ -539,6 +541,16 @@ class UserAccountBuilder private constructor() {
539541
return if (!allowUnset && clientId == null) this else apply { this.clientId = clientId }
540542
}
541543

544+
/**
545+
* Sets oauth redirect uri
546+
*
547+
* @param redirectUri oauth redirect uri.
548+
* @return Instance of this class.
549+
*/
550+
fun redirectUri(redirectUri: String?): UserAccountBuilder {
551+
return if (!allowUnset && redirectUri == null) this else apply { this.redirectUri = redirectUri }
552+
}
553+
542554
/**
543555
* Sets additional OAuth values.
544556
*
@@ -663,6 +675,7 @@ class UserAccountBuilder private constructor() {
663675
cookieSidClient,
664676
sidCookieName,
665677
clientId,
678+
redirectUri,
666679
parentSid,
667680
tokenFormat,
668681
beaconChildConsumerKey,

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -565,6 +565,7 @@ public Bundle updateAccount(Account account, UserAccount userAccount) {
565565
final String cookieSidClient = decryptUserData(account, AuthenticatorService.KEY_COOKIE_SID_CLIENT, encryptionKey);
566566
final String sidCookieName = decryptUserData(account, AuthenticatorService.KEY_SID_COOKIE_NAME, encryptionKey);
567567
final String clientId = decryptUserData(account, AuthenticatorService.KEY_CLIENT_ID, encryptionKey);
568+
final String redirectUri = decryptUserData(account, AuthenticatorService.KEY_REDIRECT_URI, encryptionKey);
568569

569570
final String parentSid = decryptUserData(account, AuthenticatorService.KEY_PARENT_SID, encryptionKey);
570571
final String tokenFormat = decryptUserData(account, AuthenticatorService.KEY_TOKEN_FORMAT, encryptionKey);
@@ -625,6 +626,7 @@ public Bundle updateAccount(Account account, UserAccount userAccount) {
625626
.cookieSidClient(cookieSidClient)
626627
.sidCookieName(sidCookieName)
627628
.clientId(clientId)
629+
.redirectUri(redirectUri)
628630
.parentSid(parentSid)
629631
.tokenFormat(tokenFormat)
630632
.beaconChildConsumerKey(beaconChildConsumerKey)
@@ -742,6 +744,7 @@ private Bundle buildAuthBundle(UserAccount userAccount) {
742744
extras.putString(AuthenticatorService.KEY_INSTANCE_URL, SalesforceSDKManager.encrypt(userAccount.getInstanceServer(), encryptionKey));
743745
extras.putString(AuthenticatorService.KEY_API_INSTANCE_URL, SalesforceSDKManager.encrypt(userAccount.getApiInstanceServer(), encryptionKey));
744746
extras.putString(AuthenticatorService.KEY_CLIENT_ID, SalesforceSDKManager.encrypt(userAccount.getClientId(), encryptionKey));
747+
extras.putString(AuthenticatorService.KEY_REDIRECT_URI, SalesforceSDKManager.encrypt(userAccount.getRedirectUri(), encryptionKey));
745748
extras.putString(AuthenticatorService.KEY_ORG_ID, SalesforceSDKManager.encrypt(userAccount.getOrgId(), encryptionKey));
746749
extras.putString(AuthenticatorService.KEY_USER_ID, SalesforceSDKManager.encrypt(userAccount.getUserId(), encryptionKey));
747750
extras.putString(AuthenticatorService.KEY_COMMUNITY_ID, SalesforceSDKManager.encrypt(userAccount.getCommunityId(), encryptionKey));

libs/SalesforceSDK/src/com/salesforce/androidsdk/accounts/UserAccountManagerExtension.kt

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,9 +29,13 @@ package com.salesforce.androidsdk.accounts
2929
import android.content.Intent
3030
import com.salesforce.androidsdk.accounts.UserAccountManager.getInstance
3131
import com.salesforce.androidsdk.app.SalesforceSDKManager
32+
import com.salesforce.androidsdk.auth.ScopeParser.Companion.toScopeParser
3233
import com.salesforce.androidsdk.config.OAuthConfig
3334
import com.salesforce.androidsdk.ui.TokenMigrationActivity
3435
import com.salesforce.androidsdk.util.SalesforceSDKLogger
36+
import kotlinx.coroutines.CoroutineScope
37+
import kotlinx.coroutines.Dispatchers.Default
38+
import kotlinx.coroutines.launch
3539
import java.util.UUID
3640

3741
const 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")
4883
fun 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

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

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ import com.salesforce.androidsdk.app.Features.FEATURE_AUTH_TYPE_WEB_SERVER_HYBRI
5151
import com.salesforce.androidsdk.app.Features.FEATURE_AUTH_TYPE_WEB_SERVER_NON_HYBRID
5252
import com.salesforce.androidsdk.app.Features.FEATURE_BEACON
5353
import com.salesforce.androidsdk.app.Features.FEATURE_BIOMETRIC_AUTH
54+
import com.salesforce.androidsdk.app.Features.FEATURE_DPOP
5455
import com.salesforce.androidsdk.app.Features.FEATURE_SCREEN_LOCK
5556
import com.salesforce.androidsdk.app.Features.FEATURE_TOKEN_FORMAT_JWT
5657
import com.salesforce.androidsdk.app.Features.FEATURE_TOKEN_FORMAT_OPAQUE
@@ -107,6 +108,7 @@ internal suspend fun onAuthFlowComplete(
107108
tokenResponse: TokenEndpointResponse,
108109
loginServer: String,
109110
consumerKey: String,
111+
redirectUri: String? = null,
110112
onAuthFlowError: (error: String, errorDesc: String?, e: Throwable?) -> Unit,
111113
onAuthFlowSuccess: (userAccount: UserAccount) -> Unit,
112114
buildAccountName: (username: String?, instanceServer: String?) -> String = ::defaultBuildAccountName,
@@ -175,6 +177,7 @@ internal suspend fun onAuthFlowComplete(
175177
.accountName(buildAccountName(userIdentity?.username, tokenResponse.instanceUrl))
176178
.loginServer(loginServer)
177179
.clientId(consumerKey)
180+
.redirectUri(redirectUri)
178181
.nativeLogin(nativeLogin)
179182
.credentialsIdentifier(credentialsIdentifier)
180183
.build()
@@ -210,6 +213,15 @@ internal suspend fun onAuthFlowComplete(
210213
} else {
211214
SalesforceSDKManager.getInstance().unregisterUsedAppFeature(FEATURE_BEACON, account)
212215
}
216+
217+
// DP: DPoP-bound session. Token migration bypasses LoginActivity.onAuthFlowSuccess (the
218+
// usual site of this marker), so an in-place upgrade to DPoP would otherwise never advertise
219+
// the flag. tokenType is a per-session property, so mirror it onto the migrated account here.
220+
if ("DPoP" == account.tokenType) {
221+
SalesforceSDKManager.getInstance().registerUsedAppFeature(FEATURE_DPOP, account)
222+
} else {
223+
SalesforceSDKManager.getInstance().unregisterUsedAppFeature(FEATURE_DPOP, account)
224+
}
213225
} else {
214226
if (nativeLogin) {
215227
// Native login bypasses LoginActivity.onAuthFlowSuccess, so A-marker per-user

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ public class AuthenticatorService extends Service {
6161
public static final String KEY_API_INSTANCE_URL = "apiInstanceUrl";
6262
public static final String KEY_USER_ID = "userId";
6363
public static final String KEY_CLIENT_ID = "clientId";
64+
public static final String KEY_REDIRECT_URI = "redirectUri";
6465
public static final String KEY_ORG_ID = "orgId";
6566
public static final String KEY_USERNAME = "username";
6667
public static final String KEY_ID_URL = "id";

0 commit comments

Comments
 (0)