From 137f05e393b66b60ceb88aceb51366e218cf35a9 Mon Sep 17 00:00:00 2001 From: Wolfgang Mathurin Date: Wed, 24 Jun 2026 16:32:28 -0600 Subject: [PATCH 1/2] W-17440369: Use instanceUrl host when refreshing OAuth tokens Adds OAuth2.overrideLoginServerIfNeeded() with precedence: communityUrl > instanceServer > loginServer instanceServer is null until after the first token response, so the code-exchange path naturally falls through to loginServer with no special casing. Updates both token-refresh call sites (ClientManager, AuthenticatorService) to use the helper, and updates getOpenIDToken() to accept and apply the same precedence. Adds a target-host log line at each refresh call site. Mirrors the iOS fix from PR #4074 (SFOAuthCredentials.overrideDomainIfNeeded). Tests: 6 new unit tests in OAuth2OverrideLoginServerTests covering precedence, fallback, community precedence, code-exchange neutrality, and malformed-URL fallback. --- .../androidsdk/auth/AuthenticatorService.java | 8 +- .../salesforce/androidsdk/auth/OAuth2.java | 46 ++++++++++- .../androidsdk/rest/ClientManager.java | 9 ++- .../auth/OAuth2OverrideLoginServerTests.kt | 76 +++++++++++++++++++ .../androidsdk/auth/OAuth2Test.java | 2 +- 5 files changed, 136 insertions(+), 5 deletions(-) create mode 100644 libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/auth/OAuth2OverrideLoginServerTests.kt diff --git a/libs/SalesforceSDK/src/com/salesforce/androidsdk/auth/AuthenticatorService.java b/libs/SalesforceSDK/src/com/salesforce/androidsdk/auth/AuthenticatorService.java index 08b112f93c..3c45b144d0 100644 --- a/libs/SalesforceSDK/src/com/salesforce/androidsdk/auth/AuthenticatorService.java +++ b/libs/SalesforceSDK/src/com/salesforce/androidsdk/auth/AuthenticatorService.java @@ -134,8 +134,14 @@ public Bundle getAuthToken(AccountAuthenticatorResponse response, Account accoun try { final Map addlParamsMap = originalUserAccount.getAdditionalOauthValues(); + final URI tokenServer = OAuth2.overrideLoginServerIfNeeded( + originalUserAccount.getLoginServer(), + originalUserAccount.getInstanceServer(), + originalUserAccount.getCommunityId(), + originalUserAccount.getCommunityUrl()); + SalesforceSDKLogger.i(TAG, "Initiating token refresh to host: " + tokenServer.getHost()); final OAuth2.TokenEndpointResponse tr = OAuth2.refreshAuthToken(HttpAccess.DEFAULT, - new URI(originalUserAccount.getLoginServer()), originalUserAccount.getClientIdForRefresh(), originalUserAccount.getRefreshToken(), addlParamsMap); + tokenServer, originalUserAccount.getClientIdForRefresh(), originalUserAccount.getRefreshToken(), addlParamsMap); UserAccount updatedUserAccount = UserAccountBuilder.getInstance() .populateFromUserAccount(originalUserAccount) diff --git a/libs/SalesforceSDK/src/com/salesforce/androidsdk/auth/OAuth2.java b/libs/SalesforceSDK/src/com/salesforce/androidsdk/auth/OAuth2.java index d37288e240..4345921e43 100644 --- a/libs/SalesforceSDK/src/com/salesforce/androidsdk/auth/OAuth2.java +++ b/libs/SalesforceSDK/src/com/salesforce/androidsdk/auth/OAuth2.java @@ -30,6 +30,7 @@ import android.text.TextUtils; import androidx.annotation.NonNull; +import androidx.annotation.Nullable; import androidx.annotation.VisibleForTesting; import androidx.annotation.WorkerThread; @@ -606,21 +607,62 @@ public static TokenEndpointResponse makeTokenEndpointRequest(HttpAccess httpAcce } } + /** + * Selects the server URI to target for token endpoint requests. + * Precedence: communityUrl > instanceServer > loginServer. + * instanceServer is populated only after the first token response, so a null instanceServer + * naturally identifies the code-exchange path which must target the login pool. + * + * @param loginServer Login server URL string (fallback, never null). + * @param instanceServer Instance server URL string, or null if not yet known. + * @param communityId Community ID, or null if not a community user. + * @param communityUrl Community URL string, or null if not a community user. + * @return The URI to use as the token endpoint base. + */ + public static URI overrideLoginServerIfNeeded(String loginServer, @Nullable String instanceServer, + @Nullable String communityId, @Nullable String communityUrl) { + if (communityId != null && communityUrl != null) { + try { + return new URI(communityUrl); + } catch (URISyntaxException e) { + SalesforceSDKLogger.w(TAG, "Invalid community URL, falling through to instanceServer", e); + } + } + if (instanceServer != null) { + try { + return new URI(instanceServer); + } catch (URISyntaxException e) { + SalesforceSDKLogger.w(TAG, "Invalid instance server URL, falling through to loginServer", e); + } + } + try { + return new URI(loginServer); + } catch (URISyntaxException e) { + throw new IllegalArgumentException("Invalid login server URL: " + loginServer, e); + } + } + /** * Fetches an OpenID token from the Salesforce backend. This requires an OpenID token to be * configured on the Salesforce connected app in the backend. It also requires the "openid" * scope to be added on the client side through bootconfig and on the connected app. * * @param loginServer Login server. + * @param instanceServer Instance server, or null if not yet known. * @param clientId Client ID. + * @param communityId Community ID, or null if not a community user. + * @param communityUrl Community URL, or null if not a community user. * @param refreshToken Refresh token. * @return OpenID token. */ - public static String getOpenIDToken(String loginServer, String clientId, String refreshToken) { + public static String getOpenIDToken(String loginServer, @Nullable String instanceServer, + String clientId, @Nullable String communityId, + @Nullable String communityUrl, String refreshToken) { String idToken = null; try { + final URI tokenServer = overrideLoginServerIfNeeded(loginServer, instanceServer, communityId, communityUrl); final TokenEndpointResponse tr = refreshAuthToken(HttpAccess.DEFAULT, - new URI(loginServer), clientId, refreshToken, null); + tokenServer, clientId, refreshToken, null); idToken = tr.idToken; } catch (Exception e) { SalesforceSDKLogger.e(TAG, "Exception thrown while fetching OpenID token", e); diff --git a/libs/SalesforceSDK/src/com/salesforce/androidsdk/rest/ClientManager.java b/libs/SalesforceSDK/src/com/salesforce/androidsdk/rest/ClientManager.java index b569ad4147..17db05cf21 100644 --- a/libs/SalesforceSDK/src/com/salesforce/androidsdk/rest/ClientManager.java +++ b/libs/SalesforceSDK/src/com/salesforce/androidsdk/rest/ClientManager.java @@ -50,6 +50,7 @@ import com.salesforce.androidsdk.app.SalesforceSDKManager; import com.salesforce.androidsdk.auth.AuthenticatorService; import com.salesforce.androidsdk.auth.HttpAccess; +import com.salesforce.androidsdk.auth.OAuth2; import com.salesforce.androidsdk.auth.OAuth2.LogoutReason; import com.salesforce.androidsdk.auth.OAuth2.OAuthFailedException; import com.salesforce.androidsdk.auth.OAuth2.TokenEndpointResponse; @@ -762,8 +763,14 @@ private UserAccount refreshStaleToken(Account account) throws NetworkErrorExcept // value avoids POSTing a stale token that would fail with invalid_grant. final String currentRefreshToken = originalUserAccount.getRefreshToken(); try { + final URI tokenServer = OAuth2.overrideLoginServerIfNeeded( + originalUserAccount.getLoginServer(), + originalUserAccount.getInstanceServer(), + originalUserAccount.getCommunityId(), + originalUserAccount.getCommunityUrl()); + SalesforceSDKLogger.i(TAG, "Initiating token refresh to host: " + tokenServer.getHost()); final TokenEndpointResponse tr = refreshAuthToken(HttpAccess.DEFAULT, - new URI(originalUserAccount.getLoginServer()), originalUserAccount.getClientIdForRefresh(), currentRefreshToken, addlParamsMap); + tokenServer, originalUserAccount.getClientIdForRefresh(), currentRefreshToken, addlParamsMap); if (tr.authToken == null) { throw new MalformedTokenException("Token endpoint returned null access token"); diff --git a/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/auth/OAuth2OverrideLoginServerTests.kt b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/auth/OAuth2OverrideLoginServerTests.kt new file mode 100644 index 0000000000..49f0846a83 --- /dev/null +++ b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/auth/OAuth2OverrideLoginServerTests.kt @@ -0,0 +1,76 @@ +/* + * Copyright (c) 2026-present, salesforce.com, inc. + * All rights reserved. + * Redistribution and use of this software in source and binary forms, with or + * without modification, are permitted provided that the following conditions + * are met: + * - Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * - Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * - Neither the name of salesforce.com, inc. nor the names of its contributors + * may be used to endorse or promote products derived from this software without + * specific prior written permission of salesforce.com, inc. + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE + * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE + * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR + * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF + * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS + * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN + * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + * POSSIBILITY OF SUCH DAMAGE. + */ +package com.salesforce.androidsdk.auth + +import com.salesforce.androidsdk.auth.OAuth2.overrideLoginServerIfNeeded +import org.junit.Assert.assertEquals +import org.junit.Test + +class OAuth2OverrideLoginServerTests { + + private val loginServer = "https://login.salesforce.com" + private val instanceServer = "https://myco.my.salesforce.com" + private val communityUrl = "https://myco.force.com/community" + private val communityId = "0DBxx0000000001" + + @Test + fun test_givenInstanceServerPopulated_whenOverrideLoginServerIfNeeded_thenReturnsInstanceServer() { + val result = overrideLoginServerIfNeeded(loginServer, instanceServer, null, null) + assertEquals(instanceServer, result.toString()) + } + + @Test + fun test_givenInstanceServerNull_whenOverrideLoginServerIfNeeded_thenReturnsLoginServer() { + val result = overrideLoginServerIfNeeded(loginServer, null, null, null) + assertEquals(loginServer, result.toString()) + } + + @Test + fun test_givenCommunityIdAndUrl_whenOverrideLoginServerIfNeeded_thenReturnsCommunityUrl() { + // communityUrl wins even when instanceServer is also set + val result = overrideLoginServerIfNeeded(loginServer, instanceServer, communityId, communityUrl) + assertEquals(communityUrl, result.toString()) + } + + @Test + fun test_givenCommunityIdButNullCommunityUrl_whenOverrideLoginServerIfNeeded_thenFallsBackToInstanceServer() { + val result = overrideLoginServerIfNeeded(loginServer, instanceServer, communityId, null) + assertEquals(instanceServer, result.toString()) + } + + @Test + fun test_givenAllNull_codeExchangePath_whenOverrideLoginServerIfNeeded_thenReturnsLoginServer() { + val result = overrideLoginServerIfNeeded(loginServer, null, null, null) + assertEquals(loginServer, result.toString()) + } + + @Test + fun test_givenMalformedCommunityUrl_whenOverrideLoginServerIfNeeded_thenFallsBackToInstanceServer() { + val result = overrideLoginServerIfNeeded(loginServer, instanceServer, communityId, "not a valid uri ://") + assertEquals(instanceServer, result.toString()) + } +} diff --git a/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/auth/OAuth2Test.java b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/auth/OAuth2Test.java index e8b60fffa7..c7c3584e61 100644 --- a/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/auth/OAuth2Test.java +++ b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/auth/OAuth2Test.java @@ -545,7 +545,7 @@ public void testParseIdentityServiceResponse() throws JSONException { @Test public void testGetOpenIDToken() { final String openIdToken = OAuth2.getOpenIDToken(TestCredentials.LOGIN_URL, - TestCredentials.CLIENT_ID, TestCredentials.REFRESH_TOKEN); + null, TestCredentials.CLIENT_ID, null, null, TestCredentials.REFRESH_TOKEN); Assert.assertNotNull("OpenID token should not be null", openIdToken); } From 527a343d44a4e69627abc2a60b7bd23cc8a16943 Mon Sep 17 00:00:00 2001 From: Wolfgang Mathurin Date: Wed, 24 Jun 2026 17:13:59 -0600 Subject: [PATCH 2/2] W-17440369: Treat empty string as null in overrideLoginServerIfNeeded MockK's relaxed mocks return "" (not null) for unstubbed Java String getters. Guard against empty strings in the same way as null to prevent new URI("") from constructing a relative URI that breaks OkHttp. Also adds a 7th test covering the empty-instanceServer case. --- .../src/com/salesforce/androidsdk/auth/OAuth2.java | 4 ++-- .../androidsdk/auth/OAuth2OverrideLoginServerTests.kt | 6 ++++++ 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/libs/SalesforceSDK/src/com/salesforce/androidsdk/auth/OAuth2.java b/libs/SalesforceSDK/src/com/salesforce/androidsdk/auth/OAuth2.java index 4345921e43..4368c8f3e7 100644 --- a/libs/SalesforceSDK/src/com/salesforce/androidsdk/auth/OAuth2.java +++ b/libs/SalesforceSDK/src/com/salesforce/androidsdk/auth/OAuth2.java @@ -621,14 +621,14 @@ public static TokenEndpointResponse makeTokenEndpointRequest(HttpAccess httpAcce */ public static URI overrideLoginServerIfNeeded(String loginServer, @Nullable String instanceServer, @Nullable String communityId, @Nullable String communityUrl) { - if (communityId != null && communityUrl != null) { + if (communityId != null && !communityId.isEmpty() && communityUrl != null && !communityUrl.isEmpty()) { try { return new URI(communityUrl); } catch (URISyntaxException e) { SalesforceSDKLogger.w(TAG, "Invalid community URL, falling through to instanceServer", e); } } - if (instanceServer != null) { + if (instanceServer != null && !instanceServer.isEmpty()) { try { return new URI(instanceServer); } catch (URISyntaxException e) { diff --git a/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/auth/OAuth2OverrideLoginServerTests.kt b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/auth/OAuth2OverrideLoginServerTests.kt index 49f0846a83..f75d165ef9 100644 --- a/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/auth/OAuth2OverrideLoginServerTests.kt +++ b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/auth/OAuth2OverrideLoginServerTests.kt @@ -73,4 +73,10 @@ class OAuth2OverrideLoginServerTests { val result = overrideLoginServerIfNeeded(loginServer, instanceServer, communityId, "not a valid uri ://") assertEquals(instanceServer, result.toString()) } + + @Test + fun test_givenEmptyInstanceServer_whenOverrideLoginServerIfNeeded_thenReturnsLoginServer() { + val result = overrideLoginServerIfNeeded(loginServer, "", null, null) + assertEquals(loginServer, result.toString()) + } }