Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -333,6 +333,8 @@ private suspend fun fetchUserIdentity(
HttpAccess.DEFAULT,
tokenResponse.idUrlWithInstance,
tokenResponse.authToken,
tokenResponse.tokenType,
tokenResponse.credentialsIdentifier,
)
}
}.onFailure { throwable ->
Expand Down
41 changes: 39 additions & 2 deletions libs/SalesforceSDK/src/com/salesforce/androidsdk/auth/OAuth2.java
Original file line number Diff line number Diff line change
Expand Up @@ -598,8 +598,42 @@ public static IdServiceResponse callIdentityService(HttpAccess httpAccessor,
String identityServiceIdUrl,
String authToken)
throws IOException {
return callIdentityService(httpAccessor, identityServiceIdUrl, authToken, null, null);
}

/**
* Calls the identity service to determine the username of the user and the mobile policy, given
* their identity service ID and an access token. When the token is DPoP-bound, attaches a DPoP
* proof header.
*
* @param httpAccessor HttpAccessor instance.
* @param identityServiceIdUrl Identity service URL.
* @param authToken Access token.
* @param tokenType Token type (e.g. "Bearer" or "DPoP"), or null for default Bearer.
* @param credentialsIdentifier Identifier used to look up the DPoP keypair, or null.
* @return IdServiceResponse instance.
*
* @throws IOException See {@link IOException}.
*/
public static IdServiceResponse callIdentityService(HttpAccess httpAccessor,
String identityServiceIdUrl,
String authToken,
@Nullable String tokenType,
@Nullable String credentialsIdentifier)
throws IOException {
final Request.Builder builder = new Request.Builder().url(identityServiceIdUrl).get();
addAuthorizationHeader(builder, authToken);
addAuthorizationHeader(builder, authToken, tokenType);
if (DPOP.equals(tokenType) && credentialsIdentifier != null && SalesforceSDKManager.getInstance().isUseDPoP()) {
try {
final String htu = DPoPURLHelper.INSTANCE.canonicalize(identityServiceIdUrl);
final String alias = DPoPKeyManager.INSTANCE.aliasForCredentialsIdentifier(credentialsIdentifier);
final KeyPair keyPair = DPoPKeyManager.INSTANCE.generateOrLoadKeyPair(alias);
final String proof = DPoPProofBuilder.INSTANCE.buildProof("GET", htu, keyPair, null, authToken);
builder.header(DPOP, proof);
} catch (Exception e) {
SalesforceSDKLogger.e(TAG, "Failed to attach DPoP header for identity service, proceeding without it", e);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Small cross-platform consistency question on the identity-service path. If proof attachment throws here we log and proceed — but the request still carries Authorization: DPoP <token> with no DPoP proof header, which the server will reject, so the fallback can't really succeed.

iOS made the identity coordinator (SFIdentityCoordinator) fail-closed for exactly this in commit 97a62410a — it bails out and surfaces the error rather than sending an unusable request. Worth noting the resource-API path is fine as-is: both platforms are fail-open there (iOS SFRestRequest logs and proceeds too), so this is just about the identity call site matching iOS.

Not asserting a change — this touches auth/credential handling, so flagging for a conscious call: either match iOS and surface the failure on the identity path, or keep a uniform fail-open policy across all DPoP paths and drop a one-line comment noting it's intentional. What's your preference?

This response was generated by an AI agent on behalf of @JohnsonEricAtSalesforce.

}
}
final Request request = builder.build();
final Response response = httpAccessor.getOkHttpClient().newCall(request).execute();
return new IdServiceResponse(response);
Expand Down Expand Up @@ -683,7 +717,9 @@ public static TokenEndpointResponse makeTokenEndpointRequest(HttpAccess httpAcce
final Request request = requestBuilder.build();
final Response response = httpAccessor.getOkHttpClient().newCall(request).execute();
if (response.isSuccessful()) {
return new TokenEndpointResponse(response);
final TokenEndpointResponse tokenResponse = new TokenEndpointResponse(response);
tokenResponse.credentialsIdentifier = credentialsIdentifier;
return tokenResponse;
} else {
throw new OAuthFailedException(new TokenErrorResponse(response), response.code());
}
Expand Down Expand Up @@ -1031,6 +1067,7 @@ public static class TokenEndpointResponse {
public String beaconChildConsumerSecret;
public String scope;
public String tokenType;
public String credentialsIdentifier;

/**
* Parameterized constructor built from params during user agent login flow.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ package com.salesforce.androidsdk.auth.dpop
import android.util.Base64
import org.json.JSONObject
import java.security.KeyPair
import java.security.MessageDigest
import java.security.SecureRandom
import java.security.Signature
import java.security.interfaces.ECPublicKey
Expand All @@ -45,7 +46,7 @@ object DPoPProofBuilder {
val publicKey = keyPair.public as ECPublicKey
val jwk = buildJwk(publicKey)
val header = buildHeader(jwk)
val payload = buildPayload(httpMethod, htu)
val payload = buildPayload(httpMethod, htu, nonce, accessToken)
val headerEncoded = base64url(header.toString().toByteArray(Charsets.UTF_8))
val payloadEncoded = base64url(payload.toString().toByteArray(Charsets.UTF_8))
val signingInput = "$headerEncoded.$payloadEncoded"
Expand Down Expand Up @@ -73,13 +74,18 @@ object DPoPProofBuilder {
put("jwk", jwk)
}

private fun buildPayload(httpMethod: String, htu: String): JSONObject {
private fun buildPayload(httpMethod: String, htu: String, nonce: String?, accessToken: String?): JSONObject {
val jti = ByteArray(12).also { SecureRandom().nextBytes(it) }
return JSONObject().apply {
put("htm", httpMethod.uppercase())
put("htu", htu)
put("iat", System.currentTimeMillis() / 1000L)
put("jti", base64url(jti))
if (!nonce.isNullOrEmpty()) put("nonce", nonce)
if (!accessToken.isNullOrEmpty()) {
val digest = MessageDigest.getInstance("SHA-256").digest(accessToken.toByteArray(Charsets.UTF_8))
put("ath", base64url(digest))
}
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -213,7 +213,7 @@ public RestClient peekRestClient(Account acc) {
userAccount.getUserId(), userAccount.getOrgId(), userAccount.getCommunityId(), userAccount.getCommunityUrl(),
userAccount.getFirstName(), userAccount.getLastName(), userAccount.getDisplayName(), userAccount.getEmail(), userAccount.getPhotoUrl(), userAccount.getThumbnailUrl(), userAccount.getAdditionalOauthValues(),
userAccount.getLightningDomain(), userAccount.getLightningSid(), userAccount.getVFDomain(), userAccount.getVFSid(), userAccount.getContentDomain(), userAccount.getContentSid(), userAccount.getCSRFToken());
return new RestClient(clientInfo, userAccount.getAuthToken(), HttpAccess.DEFAULT, authTokenProvider);
return new RestClient(clientInfo, userAccount.getAuthToken(), userAccount.getTokenType(), userAccount.getCredentialsIdentifier(), HttpAccess.DEFAULT, authTokenProvider);
} catch (URISyntaxException e) {
SalesforceSDKLogger.w(TAG, "Invalid server URL", e);
throw new AccountInfoNotFoundException("invalid server url", e);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,9 @@
import com.salesforce.androidsdk.app.SalesforceSDKManager;
import com.salesforce.androidsdk.auth.HttpAccess;
import com.salesforce.androidsdk.auth.OAuth2;
import com.salesforce.androidsdk.auth.dpop.DPoPKeyManager;
import com.salesforce.androidsdk.auth.dpop.DPoPProofBuilder;
import com.salesforce.androidsdk.auth.dpop.DPoPURLHelper;
import com.salesforce.androidsdk.security.BiometricAuthenticationManager;
import com.salesforce.androidsdk.util.SalesforceSDKLogger;

Expand Down Expand Up @@ -69,6 +72,7 @@ public class RestClient {
private static final String COMMUNITY_ID = "communityId";
private static final String COMMUNITY_URL = "communityUrl";
private static final String TAG = "RestClient";
private static final String DPOP = "DPoP";

private static final Map<String, OAuthRefreshInterceptor> OAUTH_REFRESH_INTERCEPTORS = new HashMap<>();
private static final Map<String, OkHttpClient.Builder> OK_CLIENT_BUILDERS = new HashMap<>();
Expand Down Expand Up @@ -160,10 +164,26 @@ public RestClient(ClientInfo clientInfo, String authToken, HttpAccess httpAccess
* @param authTokenProvider
*/
public RestClient(ClientInfo clientInfo, String authToken, String tokenType, HttpAccess httpAccessor, AuthTokenProvider authTokenProvider) {
this(clientInfo, authToken, tokenType, null, httpAccessor, authTokenProvider);
}

/**
* Constructs a RestClient with the given clientInfo, authToken, tokenType, credentialsIdentifier,
* httpAccessor and authTokenProvider. The tokenType determines the Authorization header scheme
* (e.g. "Bearer" or "DPoP"). The credentialsIdentifier is used to look up the DPoP key pair.
*
* @param clientInfo
* @param authToken
* @param tokenType
* @param credentialsIdentifier
* @param httpAccessor
* @param authTokenProvider
*/
public RestClient(ClientInfo clientInfo, String authToken, String tokenType, String credentialsIdentifier, HttpAccess httpAccessor, AuthTokenProvider authTokenProvider) {
this.clientInfo = clientInfo;
this.httpAccessor = httpAccessor;
this.authTokenProvider = authTokenProvider;
setOAuthRefreshInterceptor(authToken, tokenType);
setOAuthRefreshInterceptor(authToken, tokenType, credentialsIdentifier);
setOkHttpClientBuilder();
setOkHttpClient(null);
}
Expand Down Expand Up @@ -204,12 +224,19 @@ private static String computeCacheKey(String orgId, String userId) {
* Sets the OAuthRefreshInterceptor associated with this user account.
*/
private synchronized void setOAuthRefreshInterceptor(String authToken, String tokenType) {
setOAuthRefreshInterceptor(authToken, tokenType, null);
}

/**
* Sets the OAuthRefreshInterceptor associated with this user account.
*/
private synchronized void setOAuthRefreshInterceptor(String authToken, String tokenType, String credentialsIdentifier) {
final String cacheKey = getCacheKey();
OAuthRefreshInterceptor oAuthRefreshInterceptor = OAUTH_REFRESH_INTERCEPTORS.get(cacheKey);

// If none cached, create new one
if (oAuthRefreshInterceptor == null) {
oAuthRefreshInterceptor = new OAuthRefreshInterceptor(clientInfo, authToken, tokenType, authTokenProvider);
oAuthRefreshInterceptor = new OAuthRefreshInterceptor(clientInfo, authToken, tokenType, credentialsIdentifier, authTokenProvider);
OAUTH_REFRESH_INTERCEPTORS.put(cacheKey, oAuthRefreshInterceptor);
}
this.oAuthRefreshInterceptor = oAuthRefreshInterceptor;
Expand Down Expand Up @@ -380,9 +407,32 @@ public Request buildRequest(RestRequest restRequest) {
builder.addHeader(entry.getKey(), entry.getValue());
}
}

// Attach DPoP proof header for DPoP-bound access tokens
final String authToken = oAuthRefreshInterceptor.getAuthToken();
final String tokenType = oAuthRefreshInterceptor.tokenType;
final String credentialsIdentifier = oAuthRefreshInterceptor.credentialsIdentifier;
attachDPoPProofHeader(builder, restRequest.getMethod().toString(), url.toString(), authToken, tokenType, credentialsIdentifier);
Comment thread
wmathurin marked this conversation as resolved.
Outdated

return builder.build();
}

private void attachDPoPProofHeader(Request.Builder builder, String method, String url,
String accessToken, String tokenType, String credentialsIdentifier) {
if (!DPOP.equals(tokenType)) return;
if (!SalesforceSDKManager.getInstance().isUseDPoP()) return;
if (credentialsIdentifier == null || credentialsIdentifier.isEmpty()) return;
try {
final String htu = DPoPURLHelper.INSTANCE.canonicalize(url);
final String alias = DPoPKeyManager.INSTANCE.aliasForCredentialsIdentifier(credentialsIdentifier);
final java.security.KeyPair keyPair = DPoPKeyManager.INSTANCE.generateOrLoadKeyPair(alias);
final String proof = DPoPProofBuilder.INSTANCE.buildProof(method, htu, keyPair, null, accessToken);
builder.header(DPOP, proof);
} catch (Exception e) {
SalesforceSDKLogger.e(TAG, "Failed to attach DPoP header, proceeding without it", e);
}
}

/**
* Returns a new web socket for the provided request and web socket listener.
* @param request The request
Expand Down Expand Up @@ -724,7 +774,8 @@ public static class OAuthRefreshInterceptor implements Interceptor {

private final AuthTokenProvider authTokenProvider;
private String authToken;
private String tokenType;
String tokenType;
String credentialsIdentifier;
private ClientInfo clientInfo;

/**
Expand Down Expand Up @@ -753,9 +804,23 @@ public OAuthRefreshInterceptor(ClientInfo clientInfo, String authToken, AuthToke
* @param authTokenProvider
*/
public OAuthRefreshInterceptor(ClientInfo clientInfo, String authToken, String tokenType, AuthTokenProvider authTokenProvider) {
this(clientInfo, authToken, tokenType, null, authTokenProvider);
}

/**
* Overload that accepts a token type and credentials identifier for DPoP key lookup.
*
* @param clientInfo
* @param authToken
* @param tokenType
* @param credentialsIdentifier
* @param authTokenProvider
*/
public OAuthRefreshInterceptor(ClientInfo clientInfo, String authToken, String tokenType, String credentialsIdentifier, AuthTokenProvider authTokenProvider) {
this.clientInfo = clientInfo;
this.authToken = authToken;
this.tokenType = tokenType;
this.credentialsIdentifier = credentialsIdentifier;
this.authTokenProvider = authTokenProvider;
}

Expand Down Expand Up @@ -860,9 +925,25 @@ private Request adjustHostInRequest(Request request, final String host) {
private Request buildAuthenticatedRequest(Request request) {
Request.Builder builder = request.newBuilder();
setAuthHeader(builder);
attachDPoPProofIfNeeded(builder, request.method(), request.url().toString());
return builder.build();
}

private void attachDPoPProofIfNeeded(Request.Builder builder, String method, String url) {
if (!DPOP.equals(tokenType)) return;
if (!SalesforceSDKManager.getInstance().isUseDPoP()) return;
if (credentialsIdentifier == null || credentialsIdentifier.isEmpty()) return;
try {
final String htu = DPoPURLHelper.INSTANCE.canonicalize(url);
final String alias = DPoPKeyManager.INSTANCE.aliasForCredentialsIdentifier(credentialsIdentifier);
final java.security.KeyPair keyPair = DPoPKeyManager.INSTANCE.generateOrLoadKeyPair(alias);
final String proof = DPoPProofBuilder.INSTANCE.buildProof(method, htu, keyPair, null, authToken);
builder.header(DPOP, proof);
} catch (Exception e) {
SalesforceSDKLogger.e(TAG, "Failed to attach DPoP header in interceptor, proceeding without it", e);
}
}

/**
* @return The authToken for this RestClient.
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import java.security.KeyPair
import java.security.MessageDigest
import java.security.Signature

@RunWith(AndroidJUnit4::class)
Expand Down Expand Up @@ -152,4 +153,41 @@ class DPoPProofBuilderTest {
assertEquals(32, xBytes.size)
assertEquals(32, yBytes.size)
}

@Test
fun test_givenAccessToken_whenBuildProof_thenPayloadIncludesAth() {
val accessToken = "test-access-token-abc123"
val proof = DPoPProofBuilder.buildProof("GET", "https://example.com/api", keyPair, accessToken = accessToken)
val payload = decodeJson(proof.split(".")[1])
assertTrue(payload.has("ath"))
val expectedAth = Base64.encodeToString(
MessageDigest.getInstance("SHA-256").digest(accessToken.toByteArray(Charsets.UTF_8)),
Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP
)
assertEquals(expectedAth, payload.getString("ath"))
}

@Test
fun test_givenNullAccessToken_whenBuildProof_thenPayloadOmitsAth() {
val proof = DPoPProofBuilder.buildProof("GET", "https://example.com/api", keyPair, accessToken = null)
val payload = decodeJson(proof.split(".")[1])
assertFalse(payload.has("ath"))
}

@Test
fun test_givenEmptyAccessToken_whenBuildProof_thenPayloadOmitsAth() {
val proof = DPoPProofBuilder.buildProof("GET", "https://example.com/api", keyPair, accessToken = "")
val payload = decodeJson(proof.split(".")[1])
assertFalse(payload.has("ath"))
}

@Test
fun test_givenAccessToken_whenBuildProofTwice_thenAthIsIdentical() {
val accessToken = "deterministic-token"
val proof1 = DPoPProofBuilder.buildProof("GET", "https://example.com/api", keyPair, accessToken = accessToken)
val proof2 = DPoPProofBuilder.buildProof("GET", "https://example.com/api", keyPair, accessToken = accessToken)
val ath1 = decodeJson(proof1.split(".")[1]).getString("ath")
val ath2 = decodeJson(proof2.split(".")[1]).getString("ath")
assertEquals(ath1, ath2)
}
}