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..4368c8f3e7 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 && !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 && !instanceServer.isEmpty()) { + 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..f75d165ef9 --- /dev/null +++ b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/auth/OAuth2OverrideLoginServerTests.kt @@ -0,0 +1,82 @@ +/* + * 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()) + } + + @Test + fun test_givenEmptyInstanceServer_whenOverrideLoginServerIfNeeded_thenReturnsLoginServer() { + val result = overrideLoginServerIfNeeded(loginServer, "", null, null) + assertEquals(loginServer, 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); }