Skip to content

Commit 1dad5b3

Browse files
committed
fix(dpop): gate proof attachment on per-credential state
DPoP proof attachment was gated by the process-wide `SalesforceSDKManager.isUseDPoP()` flag. When the flag was flipped off while a DPoP-bound credential was still in use, subsequent refresh calls and REST / identity requests silently dropped the proof, producing a 401 loop. Gate on `tokenType == "DPoP"` OR `DPoPKeyManager.hasKeyPair(alias)` instead. The global flag continues to govern new logins only; once a credential is bound, every request for it carries a proof regardless of flag state. - DPoPKeyManager: add `hasKeyPair(alias)` side-effect-free presence check; add `shouldAttachDPoP(alias, tokenType)` belt-and-suspenders predicate. - OAuth2: widen `refreshAuthToken` with `tokenType`; replace flag guards at refresh, use_dpop_nonce retry, and identity-service paths with the new predicate. - RestClient / ClientManager / AuthenticatorService: thread `tokenType` through so the outbound-API guard honors bound credentials. - Tests: OAuth2DPoPTest + RestClientDPoPGateTests unit coverage; DPoPKeyManagerTest coverage for the presence check; AuthFlowTester UI test locks in the flag-off-then-refresh behavior.
1 parent 6bc2d07 commit 1dad5b3

10 files changed

Lines changed: 680 additions & 15 deletions

File tree

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -140,7 +140,7 @@ public Bundle getAuthToken(AccountAuthenticatorResponse response, Account accoun
140140
SalesforceSDKLogger.i(TAG, "Initiating token refresh to host: " + tokenServer.getHost());
141141
final OAuth2.TokenEndpointResponse tr = OAuth2.refreshAuthToken(HttpAccess.DEFAULT,
142142
tokenServer, originalUserAccount.getClientIdForRefresh(), originalUserAccount.getRefreshToken(), addlParamsMap,
143-
originalUserAccount.getCredentialsIdentifier());
143+
originalUserAccount.getCredentialsIdentifier(), originalUserAccount.getTokenType());
144144

145145
UserAccount updatedUserAccount = UserAccountBuilder.getInstance()
146146
.populateFromUserAccount(originalUserAccount)

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

Lines changed: 49 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -497,7 +497,8 @@ public static TokenEndpointResponse refreshAuthToken(HttpAccess httpAccessor, UR
497497

498498
/**
499499
* Gets a new auth token using the refresh token. Overload that accepts a
500-
* credentials identifier so DPoP proof can be attached when enabled.
500+
* credentials identifier so DPoP proof can be attached when enabled. Delegates
501+
* to the fully-parameterized overload with a null token type.
501502
*
502503
* @param httpAccessor HttpAccess instance.
503504
* @param loginServer Login server.
@@ -515,6 +516,34 @@ public static TokenEndpointResponse refreshAuthToken(HttpAccess httpAccessor, UR
515516
Map<String,String> addlParams,
516517
@Nullable String credentialsIdentifier)
517518
throws OAuthFailedException, IOException {
519+
return refreshAuthToken(httpAccessor, loginServer, clientId, refreshToken, addlParams,
520+
credentialsIdentifier, null);
521+
}
522+
523+
/**
524+
* Gets a new auth token using the refresh token. Fully-parameterized overload
525+
* that accepts the credential's persisted token type. When the credential is
526+
* DPoP-bound the refresh request carries a DPoP proof — independent of the
527+
* global {@code isUseDPoP} switch, which only gates new logins.
528+
*
529+
* @param httpAccessor HttpAccess instance.
530+
* @param loginServer Login server.
531+
* @param clientId Client ID.
532+
* @param refreshToken Refresh token.
533+
* @param addlParams Additional parameters.
534+
* @param credentialsIdentifier Identifier used to look up the DPoP keypair, or null.
535+
* @param tokenType Token type persisted on the credential (e.g. "DPoP" or null / "Bearer").
536+
* @return Token response.
537+
*
538+
* @throws OAuthFailedException See {@link OAuthFailedException}.
539+
* @throws IOException See {@link IOException}.
540+
*/
541+
public static TokenEndpointResponse refreshAuthToken(HttpAccess httpAccessor, URI loginServer,
542+
String clientId, String refreshToken,
543+
Map<String,String> addlParams,
544+
@Nullable String credentialsIdentifier,
545+
@Nullable String tokenType)
546+
throws OAuthFailedException, IOException {
518547
final FormBody.Builder builder = new FormBody.Builder();
519548
final boolean useHybridAuthentication = SalesforceSDKManager.getInstance().shouldUseHybridAuthentication();
520549
final String grantType = useHybridAuthentication ? HYBRID_REFRESH : REFRESH_TOKEN;
@@ -530,7 +559,7 @@ public static TokenEndpointResponse refreshAuthToken(HttpAccess httpAccessor, UR
530559
}
531560
}
532561
}
533-
return makeTokenEndpointRequest(httpAccessor, loginServer, builder, SalesforceSDKManager.getInstance(), credentialsIdentifier);
562+
return makeTokenEndpointRequest(httpAccessor, loginServer, builder, SalesforceSDKManager.getInstance(), credentialsIdentifier, tokenType);
534563
}
535564

536565
/**
@@ -619,7 +648,7 @@ public static IdServiceResponse callIdentityService(HttpAccess httpAccessor,
619648
throws IOException {
620649
final Request.Builder builder = new Request.Builder().url(identityServiceIdUrl).get();
621650
addAuthorizationHeader(builder, authToken, tokenType);
622-
if (DPOP.equals(tokenType) && credentialsIdentifier != null && SalesforceSDKManager.getInstance().isUseDPoP()) {
651+
if (DPoPKeyManager.INSTANCE.shouldAttachDPoP(credentialsIdentifier, tokenType)) {
623652
try {
624653
final String htu = DPoPURLHelper.INSTANCE.canonicalize(identityServiceIdUrl);
625654
final String alias = DPoPKeyManager.INSTANCE.aliasForCredentialsIdentifier(credentialsIdentifier);
@@ -671,7 +700,7 @@ public static TokenEndpointResponse makeTokenEndpointRequest(HttpAccess httpAcce
671700
FormBody.Builder formBodyBuilder,
672701
SalesforceSDKManager salesforceSdkManager)
673702
throws OAuthFailedException, IOException {
674-
return makeTokenEndpointRequest(httpAccessor, loginServer, formBodyBuilder, salesforceSdkManager, null);
703+
return makeTokenEndpointRequest(httpAccessor, loginServer, formBodyBuilder, salesforceSdkManager, null, null);
675704
}
676705

677706
@VisibleForTesting
@@ -682,6 +711,18 @@ public static TokenEndpointResponse makeTokenEndpointRequest(HttpAccess httpAcce
682711
SalesforceSDKManager salesforceSdkManager,
683712
@Nullable String credentialsIdentifier)
684713
throws OAuthFailedException, IOException {
714+
return makeTokenEndpointRequest(httpAccessor, loginServer, formBodyBuilder, salesforceSdkManager, credentialsIdentifier, null);
715+
}
716+
717+
@VisibleForTesting
718+
@WorkerThread
719+
public static TokenEndpointResponse makeTokenEndpointRequest(HttpAccess httpAccessor,
720+
URI loginServer,
721+
FormBody.Builder formBodyBuilder,
722+
SalesforceSDKManager salesforceSdkManager,
723+
@Nullable String credentialsIdentifier,
724+
@Nullable String tokenType)
725+
throws OAuthFailedException, IOException {
685726

686727
final StringBuilder sb = new StringBuilder(loginServer.toString());
687728
sb.append(OAUTH_TOKEN_PATH);
@@ -703,8 +744,9 @@ public static TokenEndpointResponse makeTokenEndpointRequest(HttpAccess httpAcce
703744
final String tokenHost = HttpUrl.get(refreshPath).host();
704745
final RequestBody body = formBodyBuilder.build();
705746
final Request.Builder requestBuilder = new Request.Builder().url(refreshPath).post(body);
747+
final boolean attachDPoP = DPoPKeyManager.INSTANCE.shouldAttachDPoP(credentialsIdentifier, tokenType);
706748

707-
if (credentialsIdentifier != null && salesforceSdkManager.isUseDPoP()) {
749+
if (attachDPoP) {
708750
try {
709751
final String htu = DPoPURLHelper.INSTANCE.canonicalize(refreshPath);
710752
final String alias = DPoPKeyManager.INSTANCE.aliasForCredentialsIdentifier(credentialsIdentifier);
@@ -721,16 +763,15 @@ public static TokenEndpointResponse makeTokenEndpointRequest(HttpAccess httpAcce
721763
Response response = httpAccessor.getOkHttpClient().newCall(request).execute();
722764

723765
// Harvest nonce from every response (proactive caching for next call).
724-
if (credentialsIdentifier != null && salesforceSdkManager.isUseDPoP()) {
766+
if (attachDPoP) {
725767
final String responseNonce = response.header("DPoP-Nonce");
726768
if (!TextUtils.isEmpty(responseNonce)) {
727769
DPoPNonceCache.INSTANCE.store(credentialsIdentifier, tokenHost, responseNonce);
728770
}
729771
}
730772

731773
// Nonce challenge: server requires a nonce. Retry once with the harvested nonce.
732-
if (credentialsIdentifier != null && salesforceSdkManager.isUseDPoP()
733-
&& isNonceChallenge(response)) {
774+
if (attachDPoP && isNonceChallenge(response)) {
734775
response.close();
735776
try {
736777
final String htu = DPoPURLHelper.INSTANCE.canonicalize(refreshPath);

libs/SalesforceSDK/src/com/salesforce/androidsdk/auth/dpop/DPoPKeyManager.kt

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ import java.security.spec.ECGenParameterSpec
3838
object DPoPKeyManager {
3939

4040
private const val ANDROID_KEYSTORE = "AndroidKeyStore"
41+
private const val DPOP_TOKEN_TYPE = "DPoP"
4142

4243
fun generateOrLoadKeyPair(alias: String): KeyPair {
4344
val keyStore = KeyStore.getInstance(ANDROID_KEYSTORE).apply { load(null) }
@@ -69,4 +70,38 @@ object DPoPKeyManager {
6970
}
7071

7172
fun aliasForCredentialsIdentifier(id: String): String = "dpop_$id"
73+
74+
/**
75+
* Returns true iff a key pair is already present in the AndroidKeyStore for the given alias.
76+
* Side-effect-free — does NOT mint a key pair on miss.
77+
*/
78+
fun hasKeyPair(alias: String): Boolean {
79+
if (alias.isEmpty()) return false
80+
return try {
81+
val keyStore = KeyStore.getInstance(ANDROID_KEYSTORE).apply { load(null) }
82+
keyStore.containsAlias(alias)
83+
} catch (e: Exception) {
84+
false
85+
}
86+
}
87+
88+
/** Convenience overload — computes the alias for `credentialsIdentifier` and calls `hasKeyPair`. */
89+
fun hasKeyPairForCredentialsIdentifier(credentialsIdentifier: String?): Boolean {
90+
if (credentialsIdentifier.isNullOrEmpty()) return false
91+
return hasKeyPair(aliasForCredentialsIdentifier(credentialsIdentifier))
92+
}
93+
94+
/**
95+
* Decides whether a DPoP proof should be attached to a request for a credential identified
96+
* by `credentialsIdentifier`. Returns true when either the credential's tokenType is "DPoP"
97+
* or a DPoP key pair has already been minted for the credential. Either signal alone is
98+
* sufficient — they cover the transient window between `/authorize` (which mints the key
99+
* pair before tokenType is written) and `/token` (which writes tokenType after the key pair
100+
* has been used to sign the proof).
101+
*/
102+
fun shouldAttachDPoP(credentialsIdentifier: String?, tokenType: String?): Boolean {
103+
if (credentialsIdentifier.isNullOrEmpty()) return false
104+
if (DPOP_TOKEN_TYPE == tokenType) return true
105+
return hasKeyPairForCredentialsIdentifier(credentialsIdentifier)
106+
}
72107
}

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -782,7 +782,7 @@ private UserAccount refreshStaleToken(Account account) throws NetworkErrorExcept
782782
SalesforceSDKLogger.i(TAG, "Initiating token refresh to host: " + tokenServer.getHost());
783783
final TokenEndpointResponse tr = refreshAuthToken(HttpAccess.DEFAULT,
784784
tokenServer, originalUserAccount.getClientIdForRefresh(), currentRefreshToken, addlParamsMap,
785-
originalUserAccount.getCredentialsIdentifier());
785+
originalUserAccount.getCredentialsIdentifier(), originalUserAccount.getTokenType());
786786

787787
if (tr.authToken == null) {
788788
throw new MalformedTokenException("Token endpoint returned null access token");

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

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -915,9 +915,7 @@ private Request buildAuthenticatedRequest(Request request) {
915915
}
916916

917917
private void attachDPoPProofIfNeeded(Request.Builder builder, String method, String url) {
918-
if (!DPOP.equals(tokenType)) return;
919-
if (!SalesforceSDKManager.getInstance().isUseDPoP()) return;
920-
if (credentialsIdentifier == null || credentialsIdentifier.isEmpty()) return;
918+
if (!DPoPKeyManager.INSTANCE.shouldAttachDPoP(credentialsIdentifier, tokenType)) return;
921919
try {
922920
final String htu = DPoPURLHelper.INSTANCE.canonicalize(url);
923921
final String host = HttpUrl.get(url).host();

0 commit comments

Comments
 (0)