diff --git a/.github/DangerFiles/TestOrchestrator.rb b/.github/DangerFiles/TestOrchestrator.rb index 548b1e3cbb..436e0eb71c 100644 --- a/.github/DangerFiles/TestOrchestrator.rb +++ b/.github/DangerFiles/TestOrchestrator.rb @@ -4,7 +4,8 @@ warn("Big PR, try to keep changes smaller if you can.", sticky: true) if git.lines_of_code > 1000 # Redirect contributors to PR to dev. -fail("Please re-submit this PR to the dev branch, we may have already fixed your issue.", sticky: true) if github.branch_for_base != "dev" +# dpop is a temporary exception for the multi-PR DPoP rollout. Remove once DPoP merges back to dev. +fail("Please re-submit this PR to the dev branch, we may have already fixed your issue.", sticky: true) if !["dev", "dpop"].include?(github.branch_for_base) # List of Android libraries for testing LIBS = ['SalesforceAnalytics', 'SalesforceSDK', 'SmartStore', 'MobileSync', 'SalesforceHybrid'] diff --git a/libs/SalesforceSDK/src/com/salesforce/androidsdk/accounts/UserAccountManager.java b/libs/SalesforceSDK/src/com/salesforce/androidsdk/accounts/UserAccountManager.java index 4109566dcd..875c99cc7f 100644 --- a/libs/SalesforceSDK/src/com/salesforce/androidsdk/accounts/UserAccountManager.java +++ b/libs/SalesforceSDK/src/com/salesforce/androidsdk/accounts/UserAccountManager.java @@ -556,6 +556,8 @@ public Bundle updateAccount(Account account, UserAccount userAccount) { final String beaconChildConsumerKey = decryptUserData(account, AuthenticatorService.KEY_BEACON_CHILD_CONSUMER_KEY, encryptionKey); final String beaconChildConsumerSecret = decryptUserData(account, AuthenticatorService.KEY_BEACON_CHILD_CONSUMER_SECRET, encryptionKey); final String scope = decryptUserData(account, AuthenticatorService.KEY_SCOPE, encryptionKey); + final String credentialsIdentifier = decryptUserData(account, AuthenticatorService.KEY_CREDENTIALS_IDENTIFIER, encryptionKey); + final String tokenType = decryptUserData(account, AuthenticatorService.KEY_TOKEN_TYPE, encryptionKey); final String featureFlagsRaw = decryptUserData(account, AuthenticatorService.KEY_FEATURE_FLAGS, encryptionKey); Map additionalOauthValues = null; @@ -612,6 +614,8 @@ public Bundle updateAccount(Account account, UserAccount userAccount) { .beaconChildConsumerKey(beaconChildConsumerKey) .beaconChildConsumerSecret(beaconChildConsumerSecret) .scope(scope) + .credentialsIdentifier(credentialsIdentifier) + .tokenType(tokenType) .additionalOauthValues(additionalOauthValues) .build(); if (!TextUtils.isEmpty(featureFlagsRaw)) { @@ -766,6 +770,12 @@ private Bundle buildAuthBundle(UserAccount userAccount) { extras.putString(AuthenticatorService.KEY_BEACON_CHILD_CONSUMER_KEY, SalesforceSDKManager.encrypt(userAccount.getBeaconChildConsumerKey(), encryptionKey)); extras.putString(AuthenticatorService.KEY_BEACON_CHILD_CONSUMER_SECRET, SalesforceSDKManager.encrypt(userAccount.getBeaconChildConsumerSecret(), encryptionKey)); extras.putString(AuthenticatorService.KEY_SCOPE, SalesforceSDKManager.encrypt(userAccount.getScope(), encryptionKey)); + if (userAccount.getCredentialsIdentifier() != null) { + extras.putString(AuthenticatorService.KEY_CREDENTIALS_IDENTIFIER, SalesforceSDKManager.encrypt(userAccount.getCredentialsIdentifier(), encryptionKey)); + } + if (userAccount.getTokenType() != null) { + extras.putString(AuthenticatorService.KEY_TOKEN_TYPE, SalesforceSDKManager.encrypt(userAccount.getTokenType(), encryptionKey)); + } final Set featureFlags = userAccount.getFeatureFlags(); if (!featureFlags.isEmpty()) { extras.putString(AuthenticatorService.KEY_FEATURE_FLAGS, diff --git a/libs/SalesforceSDK/src/com/salesforce/androidsdk/auth/AuthenticationUtilities.kt b/libs/SalesforceSDK/src/com/salesforce/androidsdk/auth/AuthenticationUtilities.kt index 8ff25e08e6..1fe4de1d07 100644 --- a/libs/SalesforceSDK/src/com/salesforce/androidsdk/auth/AuthenticationUtilities.kt +++ b/libs/SalesforceSDK/src/com/salesforce/androidsdk/auth/AuthenticationUtilities.kt @@ -333,6 +333,8 @@ private suspend fun fetchUserIdentity( HttpAccess.DEFAULT, tokenResponse.idUrlWithInstance, tokenResponse.authToken, + tokenResponse.tokenType, + tokenResponse.credentialsIdentifier, ) } }.onFailure { throwable -> diff --git a/libs/SalesforceSDK/src/com/salesforce/androidsdk/auth/AuthenticatorService.java b/libs/SalesforceSDK/src/com/salesforce/androidsdk/auth/AuthenticatorService.java index 66b98503fb..0d18c0907e 100644 --- a/libs/SalesforceSDK/src/com/salesforce/androidsdk/auth/AuthenticatorService.java +++ b/libs/SalesforceSDK/src/com/salesforce/androidsdk/auth/AuthenticatorService.java @@ -93,6 +93,8 @@ public class AuthenticatorService extends Service { public static final String KEY_BEACON_CHILD_CONSUMER_SECRET = "auto_installed_app_org_consumer_secret"; public static final String KEY_SCOPE = "scope"; public static final String KEY_FEATURE_FLAGS = "feature_flags"; + public static final String KEY_CREDENTIALS_IDENTIFIER = "credentialsIdentifier"; + public static final String KEY_TOKEN_TYPE = "tokenType"; private static final String TAG = "AuthenticatorService"; diff --git a/libs/SalesforceSDK/src/com/salesforce/androidsdk/auth/OAuth2.java b/libs/SalesforceSDK/src/com/salesforce/androidsdk/auth/OAuth2.java index c9f0f440f2..5fb7aecce2 100644 --- a/libs/SalesforceSDK/src/com/salesforce/androidsdk/auth/OAuth2.java +++ b/libs/SalesforceSDK/src/com/salesforce/androidsdk/auth/OAuth2.java @@ -598,8 +598,41 @@ 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()) { + // Fail-closed: a DPoP-bound identity request without a proof header will be rejected + // by the server regardless, so surface the error rather than sending an unusable request. + // This matches iOS SFIdentityCoordinator behaviour (commit 97a62410a). + 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); + } final Request request = builder.build(); final Response response = httpAccessor.getOkHttpClient().newCall(request).execute(); return new IdServiceResponse(response); @@ -683,7 +716,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()); } @@ -1031,6 +1066,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. diff --git a/libs/SalesforceSDK/src/com/salesforce/androidsdk/auth/dpop/DPoPProofBuilder.kt b/libs/SalesforceSDK/src/com/salesforce/androidsdk/auth/dpop/DPoPProofBuilder.kt index a057a29a2a..46e0a69fb7 100644 --- a/libs/SalesforceSDK/src/com/salesforce/androidsdk/auth/dpop/DPoPProofBuilder.kt +++ b/libs/SalesforceSDK/src/com/salesforce/androidsdk/auth/dpop/DPoPProofBuilder.kt @@ -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 @@ -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" @@ -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)) + } } } diff --git a/libs/SalesforceSDK/src/com/salesforce/androidsdk/rest/ClientManager.java b/libs/SalesforceSDK/src/com/salesforce/androidsdk/rest/ClientManager.java index 62c2069e56..349f955dac 100644 --- a/libs/SalesforceSDK/src/com/salesforce/androidsdk/rest/ClientManager.java +++ b/libs/SalesforceSDK/src/com/salesforce/androidsdk/rest/ClientManager.java @@ -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); diff --git a/libs/SalesforceSDK/src/com/salesforce/androidsdk/rest/RestClient.java b/libs/SalesforceSDK/src/com/salesforce/androidsdk/rest/RestClient.java index 0a0da004a9..850ae3fff0 100644 --- a/libs/SalesforceSDK/src/com/salesforce/androidsdk/rest/RestClient.java +++ b/libs/SalesforceSDK/src/com/salesforce/androidsdk/rest/RestClient.java @@ -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; @@ -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 OAUTH_REFRESH_INTERCEPTORS = new HashMap<>(); private static final Map OK_CLIENT_BUILDERS = new HashMap<>(); @@ -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); } @@ -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; @@ -380,6 +407,7 @@ public Request buildRequest(RestRequest restRequest) { builder.addHeader(entry.getKey(), entry.getValue()); } } + return builder.build(); } @@ -724,7 +752,8 @@ public static class OAuthRefreshInterceptor implements Interceptor { private final AuthTokenProvider authTokenProvider; private String authToken; - private String tokenType; + String tokenType; + String credentialsIdentifier; private ClientInfo clientInfo; /** @@ -753,9 +782,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; } @@ -860,9 +903,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. */ diff --git a/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/accounts/UserAccountManagerTest.java b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/accounts/UserAccountManagerTest.java index 30b5b6425a..62c3b30f53 100644 --- a/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/accounts/UserAccountManagerTest.java +++ b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/accounts/UserAccountManagerTest.java @@ -27,6 +27,8 @@ package com.salesforce.androidsdk.accounts; import static androidx.core.content.ContextCompat.RECEIVER_NOT_EXPORTED; +import static com.salesforce.androidsdk.accounts.UserAccountTest.TEST_CREDENTIALS_IDENTIFIER; +import static com.salesforce.androidsdk.accounts.UserAccountTest.TEST_TOKEN_TYPE; import static com.salesforce.androidsdk.accounts.UserAccountTest.TEST_USERNAME; import static com.salesforce.androidsdk.accounts.UserAccountTest.checkSameUserAccount; @@ -41,6 +43,7 @@ import androidx.test.filters.SmallTest; import androidx.test.platform.app.InstrumentationRegistry; +import com.salesforce.androidsdk.accounts.UserAccountBuilder; import com.salesforce.androidsdk.app.SalesforceSDKManager; import com.salesforce.androidsdk.auth.OAuth2; import com.salesforce.androidsdk.security.SalesforceKeyGenerator; @@ -192,6 +195,57 @@ public void testGetRefreshTokenReflectsLatestPersistedValue() { staleUser.getRefreshToken()); } + /* + * Regression test for DPoP fields not persisted in AccountManager. + * + * credentialsIdentifier and tokenType were omitted from buildAuthBundle / + * buildUserAccount, so both came back null after any process restart. + * This caused the DPoP proof to be skipped on token refresh, resulting in + * the server rejecting the request with "app requires proof of possession". + */ + @Test + public void test_givenDPoPAccount_whenCreateAndBuildUserAccount_thenCredentialsIdentifierAndTokenTypeRoundTrip() { + UserAccount userAccount = UserAccountTest.createTestAccount(); + Assert.assertEquals("Precondition: test account must have credentialsIdentifier set", + TEST_CREDENTIALS_IDENTIFIER, userAccount.getCredentialsIdentifier()); + Assert.assertEquals("Precondition: test account must have tokenType set", + TEST_TOKEN_TYPE, userAccount.getTokenType()); + + userAccMgr.createAccount(userAccount); + Account account = userAccMgr.getCurrentAccount(); + UserAccount restored = userAccMgr.buildUserAccount(account); + + Assert.assertEquals("credentialsIdentifier must survive createAccount → buildUserAccount round-trip", + TEST_CREDENTIALS_IDENTIFIER, restored.getCredentialsIdentifier()); + Assert.assertEquals("tokenType must survive createAccount → buildUserAccount round-trip", + TEST_TOKEN_TYPE, restored.getTokenType()); + } + + /* + * Regression test: updateAccount must also persist credentialsIdentifier and tokenType. + */ + @Test + public void test_givenDPoPAccount_whenUpdateAccount_thenCredentialsIdentifierAndTokenTypeRoundTrip() { + UserAccount original = UserAccountTest.createTestAccount(); + userAccMgr.createAccount(original); + Account account = userAccMgr.getCurrentAccount(); + + final String newCredId = "updated-credentials-id"; + final String newTokenType = "DPoP"; + UserAccount updated = UserAccountBuilder.getInstance() + .populateFromUserAccount(original) + .credentialsIdentifier(newCredId) + .tokenType(newTokenType) + .build(); + userAccMgr.updateAccount(account, updated); + + UserAccount restored = userAccMgr.buildUserAccount(account); + Assert.assertEquals("credentialsIdentifier must survive updateAccount → buildUserAccount round-trip", + newCredId, restored.getCredentialsIdentifier()); + Assert.assertEquals("tokenType must survive updateAccount → buildUserAccount round-trip", + newTokenType, restored.getTokenType()); + } + /** * Test to get all authenticated users. */ diff --git a/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/accounts/UserAccountTest.java b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/accounts/UserAccountTest.java index 82269c39f7..7d4dbe0057 100644 --- a/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/accounts/UserAccountTest.java +++ b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/accounts/UserAccountTest.java @@ -107,6 +107,8 @@ public class UserAccountTest { public static final String TEST_BEACON_CHILD_CONSUMER_KEY = "test-beacon-child-consumer-key"; public static final String TEST_BEACON_CHILD_CONSUMER_SECRET = "test-beacon-child-consumer-secret"; public static final String TEST_SCOPE = "api web openid refresh_token"; + public static final String TEST_CREDENTIALS_IDENTIFIER = "test-credentials-identifier-uuid"; + public static final String TEST_TOKEN_TYPE = "DPoP"; // other user public static final String TEST_ORG_ID_2 = "test_org_id_2"; @@ -428,6 +430,8 @@ private JSONObject createTestAccountJSON() throws JSONException{ object.put(UserAccount.SCOPE, TEST_SCOPE); object.put(UserAccount.BEACON_CHILD_CONSUMER_KEY, TEST_BEACON_CHILD_CONSUMER_KEY); object.put(UserAccount.BEACON_CHILD_CONSUMER_SECRET, TEST_BEACON_CHILD_CONSUMER_SECRET); + object.put(UserAccount.CREDENTIALS_IDENTIFIER, TEST_CREDENTIALS_IDENTIFIER); + object.put(UserAccount.TOKEN_TYPE, TEST_TOKEN_TYPE); object = MapUtil.addMapToJSONObject(createAdditionalOauthValues(), createAdditionalOauthKeys(), object); return object; } @@ -476,6 +480,8 @@ private Bundle createTestAccountBundle() { object.putString(UserAccount.BEACON_CHILD_CONSUMER_KEY, TEST_BEACON_CHILD_CONSUMER_KEY); object.putString(UserAccount.BEACON_CHILD_CONSUMER_SECRET, TEST_BEACON_CHILD_CONSUMER_SECRET); object.putString(UserAccount.SCOPE, TEST_SCOPE); + object.putString(UserAccount.CREDENTIALS_IDENTIFIER, TEST_CREDENTIALS_IDENTIFIER); + object.putString(UserAccount.TOKEN_TYPE, TEST_TOKEN_TYPE); object = MapUtil.addMapToBundle(createAdditionalOauthValues(), createAdditionalOauthKeys(), object); return object; } @@ -522,6 +528,8 @@ public static UserAccount createTestAccount() { .beaconChildConsumerKey(TEST_BEACON_CHILD_CONSUMER_KEY) .beaconChildConsumerSecret(TEST_BEACON_CHILD_CONSUMER_SECRET) .scope(TEST_SCOPE) + .credentialsIdentifier(TEST_CREDENTIALS_IDENTIFIER) + .tokenType(TEST_TOKEN_TYPE) .additionalOauthValues(createAdditionalOauthValues()) .build(); } diff --git a/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/auth/AuthenticatorServiceTest.kt b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/auth/AuthenticatorServiceTest.kt index 3faabee6dc..b464a56073 100644 --- a/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/auth/AuthenticatorServiceTest.kt +++ b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/auth/AuthenticatorServiceTest.kt @@ -59,6 +59,7 @@ class AuthenticatorServiceTest { every { additionalOauthKeys } returns emptyList() every { useHybridAuthentication } returns true every { appAttestationClient } returns null + every { isUseDPoP() } returns false @Suppress("UNCHECKED_CAST") every { loginActivityClass } returns Class.forName("com.salesforce.androidsdk.ui.LoginActivity") as Class } diff --git a/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/auth/LoginViewModelMockTest.kt b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/auth/LoginViewModelMockTest.kt index 56e77380ce..399896fa92 100644 --- a/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/auth/LoginViewModelMockTest.kt +++ b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/auth/LoginViewModelMockTest.kt @@ -125,6 +125,7 @@ class LoginViewModelMockTest { buildAccountName = any(), nativeLogin = any(), tokenMigration = any(), + credentialsIdentifier = any(), context = any(), userAccountManager = any(), blockIntegrationUser = any(), @@ -164,6 +165,7 @@ class LoginViewModelMockTest { buildAccountName = any(), nativeLogin = any(), tokenMigration = false, + credentialsIdentifier = any(), context = any(), userAccountManager = any(), blockIntegrationUser = any(), @@ -194,6 +196,7 @@ class LoginViewModelMockTest { buildAccountName = any(), nativeLogin = any(), tokenMigration = any(), + credentialsIdentifier = any(), context = any(), userAccountManager = any(), blockIntegrationUser = any(), @@ -232,6 +235,7 @@ class LoginViewModelMockTest { buildAccountName = any(), nativeLogin = any(), tokenMigration = false, + credentialsIdentifier = any(), context = any(), userAccountManager = any(), blockIntegrationUser = any(), @@ -262,6 +266,7 @@ class LoginViewModelMockTest { buildAccountName = any(), nativeLogin = any(), tokenMigration = any(), + credentialsIdentifier = any(), context = any(), userAccountManager = any(), blockIntegrationUser = any(), @@ -310,6 +315,7 @@ class LoginViewModelMockTest { buildAccountName = any(), nativeLogin = any(), tokenMigration = any(), + credentialsIdentifier = any(), context = any(), userAccountManager = any(), blockIntegrationUser = any(), @@ -347,6 +353,7 @@ class LoginViewModelMockTest { buildAccountName = any(), nativeLogin = any(), tokenMigration = false, + credentialsIdentifier = any(), context = any(), userAccountManager = any(), blockIntegrationUser = any(), @@ -534,6 +541,7 @@ class LoginViewModelMockTest { buildAccountName = any(), nativeLogin = any(), tokenMigration = any(), + credentialsIdentifier = any(), context = any(), userAccountManager = any(), blockIntegrationUser = any(), @@ -571,6 +579,7 @@ class LoginViewModelMockTest { buildAccountName = any(), nativeLogin = any(), tokenMigration = true, + credentialsIdentifier = any(), context = any(), userAccountManager = any(), blockIntegrationUser = any(), @@ -644,12 +653,12 @@ class LoginViewModelMockTest { TIMESTAMP_FORMAT mockkStatic(OAuth2::class) every { - exchangeCode(any(), any(), any(), any(), any(), any()) + exchangeCode(any(), any(), any(), any(), any(), any(), any(), any()) } returns mockTokenResponse // Mock doCodeExchange to prevent actual execution coEvery { - spyViewModel.onAuthFlowComplete(any(), any(), any(), any(), any()) + spyViewModel.onAuthFlowComplete(any(), any(), any(), any(), any(), any()) } just runs // Set up required state @@ -673,6 +682,7 @@ class LoginViewModelMockTest { mockOnSuccess, tokenMigration = false, loginServer = "https://test.salesforce.com", + credentialsIdentifier = any(), ) } } @@ -692,12 +702,12 @@ class LoginViewModelMockTest { TIMESTAMP_FORMAT mockkStatic(OAuth2::class) every { - exchangeCode(any(), any(), any(), any(), any(), any()) + exchangeCode(any(), any(), any(), any(), any(), any(), any(), any()) } returns mockTokenResponse // Mock doCodeExchange to prevent actual execution coEvery { - spyViewModel.onAuthFlowComplete(any(), any(), any(), any(), any()) + spyViewModel.onAuthFlowComplete(any(), any(), any(), any(), any(), any()) } just runs // Set up required state @@ -723,6 +733,7 @@ class LoginViewModelMockTest { mockOnSuccess, tokenMigration = true, loginServer = migrationServer, + credentialsIdentifier = any(), ) } } @@ -746,13 +757,13 @@ class LoginViewModelMockTest { TIMESTAMP_FORMAT mockkStatic(OAuth2::class) every { - exchangeCode(any(), any(), any(), any(), any(), any()) + exchangeCode(any(), any(), any(), any(), any(), any(), any(), any()) } returns mockTokenResponse // Spy so we can short-circuit account creation, leaving exchangeCode as the observable. val spyViewModel = spyk(viewModel) coEvery { - spyViewModel.onAuthFlowComplete(any(), any(), any(), any(), any()) + spyViewModel.onAuthFlowComplete(any(), any(), any(), any(), any(), any()) } just runs // Sanity: distinct from boot config so a missing side effect would surface. @@ -788,6 +799,8 @@ class LoginViewModelMockTest { /* code = */ testCode, /* codeVerifier = */ any(), /* callbackUrl = */ migrationRedirectUri, + /* salesforceSdkManager = */ any(), + /* credentialsIdentifier = */ any(), ) } } @@ -800,7 +813,7 @@ class LoginViewModelMockTest { TIMESTAMP_FORMAT mockkStatic(OAuth2::class) every { - exchangeCode(any(), any(), any(), any(), any(), any()) + exchangeCode(any(), any(), any(), any(), any(), any(), any(), any()) } throws throws } @@ -871,7 +884,7 @@ class LoginViewModelMockTest { setupExchangeCodeMock(oauthException) coEvery { - spyViewModel.onAuthFlowComplete(any(), any(), any(), any(), any()) + spyViewModel.onAuthFlowComplete(any(), any(), any(), any(), any(), any()) } just runs spyViewModel.selectedServer.value = "https://test.salesforce.com" @@ -880,7 +893,7 @@ class LoginViewModelMockTest { verify { mockOnError("Token Request Error", any(), oauthException) } verify(exactly = 0) { mockOnSuccess(any()) } coVerify(exactly = 0) { - spyViewModel.onAuthFlowComplete(any(), any(), any(), any(), any()) + spyViewModel.onAuthFlowComplete(any(), any(), any(), any(), any(), any()) } } diff --git a/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/auth/dpop/DPoPProofBuilderTest.kt b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/auth/dpop/DPoPProofBuilderTest.kt index 9182d89a50..70ad6f7270 100644 --- a/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/auth/dpop/DPoPProofBuilderTest.kt +++ b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/auth/dpop/DPoPProofBuilderTest.kt @@ -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) @@ -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) + } } diff --git a/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/rest/ClientManagerMockTest.kt b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/rest/ClientManagerMockTest.kt index f80c3b7341..f37e397922 100644 --- a/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/rest/ClientManagerMockTest.kt +++ b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/rest/ClientManagerMockTest.kt @@ -89,6 +89,7 @@ class ClientManagerMockTest { every { appAttestationClient } returns null every { appContext } returns mockAppContext every { isDevSupportEnabled() } returns true + every { isUseDPoP() } returns false } every { SalesforceSDKManager.getInstance() } returns mockSDKManager mockkStatic(UserAccountManager::class) @@ -98,7 +99,7 @@ class ClientManagerMockTest { val responseBody = """ { - "access_token": $REFRESHED_ACCESS_TOKEN, + "access_token": "$REFRESHED_ACCESS_TOKEN", "instance_url": "https://login.salesforce.com", "id": "https://login.salesforce.com/id/orgId/userId", "token_type": "Bearer", diff --git a/native/NativeSampleApps/AuthFlowTester/src/main/java/com/salesforce/samples/authflowtester/AuthFlowTesterApplication.kt b/native/NativeSampleApps/AuthFlowTester/src/main/java/com/salesforce/samples/authflowtester/AuthFlowTesterApplication.kt index 79d8bc8b4b..cb41e999e9 100644 --- a/native/NativeSampleApps/AuthFlowTester/src/main/java/com/salesforce/samples/authflowtester/AuthFlowTesterApplication.kt +++ b/native/NativeSampleApps/AuthFlowTester/src/main/java/com/salesforce/samples/authflowtester/AuthFlowTesterApplication.kt @@ -44,6 +44,7 @@ class AuthFlowTesterApplication : Application() { with(SalesforceSDKManager.getInstance()) { registerUsedAppFeature(FEATURE_APP_USES_KOTLIN) + setUseDPoP(true) } } } \ No newline at end of file diff --git a/native/NativeSampleApps/AuthFlowTester/src/main/res/xml/servers.xml b/native/NativeSampleApps/AuthFlowTester/src/main/res/xml/servers.xml index e75e382726..43f9aa8221 100644 --- a/native/NativeSampleApps/AuthFlowTester/src/main/res/xml/servers.xml +++ b/native/NativeSampleApps/AuthFlowTester/src/main/res/xml/servers.xml @@ -1,5 +1,6 @@ +