diff --git a/libs/SalesforceSDK/src/com/salesforce/androidsdk/auth/dpop/DPoPNonceCache.kt b/libs/SalesforceSDK/src/com/salesforce/androidsdk/auth/dpop/DPoPNonceCache.kt index a250ea51a6..4bcd30dd71 100644 --- a/libs/SalesforceSDK/src/com/salesforce/androidsdk/auth/dpop/DPoPNonceCache.kt +++ b/libs/SalesforceSDK/src/com/salesforce/androidsdk/auth/dpop/DPoPNonceCache.kt @@ -31,32 +31,52 @@ import java.util.concurrent.ConcurrentHashMap /** * Thread-safe in-memory nonce cache for DPoP proof JWTs. * - * RFC 9449 §8 allows the AS/RS to supply a `DPoP-Nonce` response header. The - * client must echo that value in the `nonce` claim of its next DPoP proof for the - * same endpoint. This cache is keyed by `(credentialsIdentifier, host)` so that - * the AS nonce (login host) and RS nonce (instance host) never overwrite each other. - * This matches the per-host isolation used by the iOS implementation, while also - * ensuring per-user isolation consistent with [DPoPKeyManager]. + * RFC 9449 §8 allows a server to supply a `DPoP-Nonce` response header. Salesforce + * issues nonces from the token endpoint; resource-server responses (identity, REST) + * are not expected to carry one, though the SDK still harvests a `DPoP-Nonce` from + * any response that does (e.g. the userinfo nonce-challenge retry in + * `AuthenticationUtilities`). This cache is keyed by `(credentialsIdentifier, host)` + * so a nonce supplied by a specific host takes precedence for that host. + * + * Since resource servers aren't expected to issue their own nonce, [get] falls back to the most + * recently stored nonce for [credentialsIdentifier] (any host) when there is no entry + * for the exact `(credentialsIdentifier, host)` pair, so the client reuses the latest + * token-endpoint nonce on every DPoP call for that credential. An exact host match + * always takes precedence over the fallback. Logins where the token host differs from + * the resource hosts (e.g. communities, login.* pool servers) rely on this fallback. + * This matches the credential-scoped fallback used by the iOS implementation + * (`DPoPNonceCache.swift`'s `nonce(htu:scope:) ?? latest(forScope:)`), layered on top + * of Android's existing per-host isolation. */ object DPoPNonceCache { private val cache = ConcurrentHashMap() + private val latestByCredential = ConcurrentHashMap() private fun cacheKey(credentialsIdentifier: String, host: String) = "$credentialsIdentifier|$host" + /** + * Returns the nonce cached for the exact `(credentialsIdentifier, host)` pair, or, + * if none was ever stored for that host, the most recently stored nonce for + * [credentialsIdentifier] on any host — i.e. the latest nonce issued at the token + * endpoint for this credential. Returns null if neither is available. + */ fun get(credentialsIdentifier: String, host: String): String? = - cache[cacheKey(credentialsIdentifier, host)] + cache[cacheKey(credentialsIdentifier, host)] ?: latestByCredential[credentialsIdentifier] fun store(credentialsIdentifier: String, host: String, nonce: String) { cache[cacheKey(credentialsIdentifier, host)] = nonce + latestByCredential[credentialsIdentifier] = nonce } fun clear(credentialsIdentifier: String) { cache.keys.removeAll { it.startsWith("$credentialsIdentifier|") } + latestByCredential.remove(credentialsIdentifier) } fun clearAll() { cache.clear() + latestByCredential.clear() } } diff --git a/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/auth/AuthenticationUtilitiesIntegrationUserTest.kt b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/auth/AuthenticationUtilitiesIntegrationUserTest.kt index 6c27c4f2d7..50c31bfbc1 100644 --- a/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/auth/AuthenticationUtilitiesIntegrationUserTest.kt +++ b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/auth/AuthenticationUtilitiesIntegrationUserTest.kt @@ -208,11 +208,22 @@ class AuthenticationUtilitiesIntegrationUserTest { * instance), the harvested nonce must be stored under the response's * host, not the pre-redirect request's host — otherwise a retry proof * built for the response's host never finds it. + * + * The pre-redirect host is pre-seeded with its own, distinct nonce so + * this is a real test of per-host storage rather than of + * [DPoPNonceCache.get]'s cross-host fallback (which would otherwise mask + * a regression here, since the fallback returns the credential's latest + * nonce for any host with no exact entry — including the pre-redirect + * host — and `instance-nonce` would satisfy both assertions below even + * if the harvest wrongly landed on the pre-redirect host). The exact + * `(credentialsIdentifier, "instance.test")` entry staying at the + * pre-seeded value proves the harvest never overwrote it. */ @Test fun test_fetchIsSalesforceIntegrationUser_crossHostRedirect_harvestsNonceUnderResponseHost() { generateOrLoadKeyPair(alias) clear(credentialsIdentifier) + store(credentialsIdentifier, "instance.test", "pre-redirect-nonce") httpAccess.enqueueIntegrationUserSuccess( isIntegrationUser = false, headers = mapOf("DPoP-Nonce" to "instance-nonce"), @@ -223,8 +234,9 @@ class AuthenticationUtilitiesIntegrationUserTest { fetchIsSalesforceIntegrationUser(tokenResponse, "https://login.salesforce.com") - assertNull( - "Nonce must not be stored under the pre-redirect request host", + assertEquals( + "Pre-redirect request host's own nonce must not be overwritten by the response host's harvest", + "pre-redirect-nonce", get(credentialsIdentifier, "instance.test") ) assertEquals( diff --git a/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/auth/dpop/DPoPNonceCacheTest.kt b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/auth/dpop/DPoPNonceCacheTest.kt index 1194c0b090..2a3c3e3ec9 100644 --- a/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/auth/dpop/DPoPNonceCacheTest.kt +++ b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/auth/dpop/DPoPNonceCacheTest.kt @@ -90,6 +90,46 @@ class DPoPNonceCacheTest { assertEquals("RS nonce must be retrievable by instance host", rsNonce, DPoPNonceCache.get(id, instanceHost)) } + /* + * Salesforce issues DPoP-Nonce from the token endpoint; resource servers (identity, + * REST) are not expected to issue their own, so the client must carry forward + * the nonce most recently issued for that credential, regardless of host. When no + * nonce was ever stored for the exact (credentialsIdentifier, host) pair, get() must + * fall back to the most recently stored nonce for that credential on any host. + */ + @Test + fun test_givenNonceStoredForOtherHost_whenGetForUnseenHost_thenFallbackNonceReturned() { + val tokenHost = "community.my.site.com" + val resourceHost = "community.my.salesforce.com" + DPoPNonceCache.store(id, tokenHost, "token-host-nonce") + assertEquals("token-host-nonce", DPoPNonceCache.get(id, resourceHost)) + } + + @Test + fun test_givenExactHostMatch_whenFallbackAlsoAvailable_thenExactMatchTakesPrecedence() { + DPoPNonceCache.store(id, loginHost, "fallback-nonce") + DPoPNonceCache.store(id, instanceHost, "exact-nonce") + assertEquals("exact-nonce", DPoPNonceCache.get(id, instanceHost)) + } + + @Test + fun test_givenFallbackNonceStored_whenClear_thenFallbackIsAlsoRemoved() { + DPoPNonceCache.store(id, loginHost, "token-host-nonce") + DPoPNonceCache.clear(id) + assertNull(DPoPNonceCache.get(id, "some.other.host.salesforce.com")) + } + + @Test + fun test_givenTwoDifferentIdentifiers_whenOneHasFallbackOnly_thenOtherIdentifierDoesNotLeak() { + val id2 = "user2" + DPoPNonceCache.store(id, loginHost, "user1-nonce") + // id2 never stores anything, including for loginHost or any other host. + assertNull(DPoPNonceCache.get(id2, loginHost)) + assertNull(DPoPNonceCache.get(id2, instanceHost)) + // id's fallback must still resolve correctly for a host it never used directly. + assertEquals("user1-nonce", DPoPNonceCache.get(id, instanceHost)) + } + @Test fun test_givenConcurrentWrites_whenMultipleThreads_thenNoRace() { val threadCount = 20 diff --git a/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/auth/dpop/DPoPRequestDecoratorTest.kt b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/auth/dpop/DPoPRequestDecoratorTest.kt index 97eea51d5b..8935f84e58 100644 --- a/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/auth/dpop/DPoPRequestDecoratorTest.kt +++ b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/auth/dpop/DPoPRequestDecoratorTest.kt @@ -26,6 +26,7 @@ */ package com.salesforce.androidsdk.auth.dpop +import android.util.Base64 import androidx.test.ext.junit.runners.AndroidJUnit4 import com.salesforce.androidsdk.accounts.UserAccount import com.salesforce.androidsdk.accounts.UserAccountBuilder @@ -34,6 +35,7 @@ import okhttp3.Protocol import okhttp3.Request import okhttp3.Response import okhttp3.ResponseBody.Companion.toResponseBody +import org.json.JSONObject import org.junit.After import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse @@ -291,6 +293,42 @@ class DPoPRequestDecoratorTest { } } + private fun decodeJson(segment: String): JSONObject = JSONObject( + String( + Base64.decode(segment, Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP), + Charsets.UTF_8, + ), + ) + + /* + * Community logins: the token host differs from the resource hosts (identity, REST), + * which reject a proof carrying no nonce. The client must reuse the nonce + * harvested from the /token host when attaching a proof for a different (resource) + * host under the same credential. + */ + @Test + fun applyAuthHeaders_dpopAccount_nonceHarvestedOnDifferentHost_fallsBackToLatestNonce() { + DPoPNonceCache.clearAll() + try { + seedKeyPair(testScope) + DPoPNonceCache.store(testScope, "community.my.site.com", "token-host-nonce") + + val builder = Request.Builder().url("https://community.my.salesforce.com/id/orgId/userId").get() + DPoPRequestDecorator.applyAuthHeaders(builder, userAccount(tokenType = "DPoP")) + + val proof = builder.build().header(DPoPRequestDecorator.DPOP_HEADER) + assertNotNull("Expected a DPoP proof header", proof) + val payload = decodeJson(proof!!.split(".")[1]) + assertEquals( + "Expected the /token-host nonce to be reused for the resource host", + "token-host-nonce", + payload.getString("nonce"), + ) + } finally { + DPoPNonceCache.clearAll() + } + } + @Test fun applyAuthHeaders_lowercaseDPoPTokenType_stampsDPoPSchemeNotBearer() { // Regression for W-24027018: server returns lowercase "dpop" in token refresh responses. diff --git a/native/NativeSampleApps/AuthFlowTester/README.md b/native/NativeSampleApps/AuthFlowTester/README.md index 5af5c4d305..c27a71e606 100644 --- a/native/NativeSampleApps/AuthFlowTester/README.md +++ b/native/NativeSampleApps/AuthFlowTester/README.md @@ -84,6 +84,27 @@ All DPoP tests live here — basic login, RTR, multi-user, migration, server enf | `testECAJwtDPoPRtr_RevokedBeforeManyRequests_RotatesAndPreservesBinding` | ECA JWT DPoP RTR | — | Twenty concurrent 401s share one refresh; access and refresh tokens rotate while DPoP binding remains valid | | `testECAJwtDPoPRtr_AfterRestart_ManyRequestNonceRecoverySucceeds` | ECA JWT DPoP RTR | — | Cold nonce cache plus concurrent refresh; at most one nonce challenge/retry; key binding survives restart | +#### CommunityLoginTests +Tests for login against a community (Experience Cloud) login host, using the `community_auth` login host and the existing ECAs (no dedicated community app config — the community in the AuthFlowTester test org is set up so the existing apps' consumer keys/redirect URIs work unchanged for the community user; see `CommunityLoginTests`' class doc for the app chosen per scenario). Follows up on the DPoP + community investigation (W-24342940): community sites route through an Experience Cloud front door rather than a plain My Domain host, so these tests confirm the existing login/DPoP/multi-user/restart/migration helpers behave the same way against that front door as they do against `regular_auth`. `community_auth` requires a dedicated Experience Cloud site and is not provisioned in every environment — every test skips cleanly (`Assume.assumeTrue`) when the host is absent from `ui_test_config.json`. The `community_auth` login host provisions only one user, so cross-host multi-user coverage pairs it with a `regular_auth` DPoP user instead of a second `community_auth` user. Each refresh-based test also asserts that the stored community URL equals the configured `community_auth` URL (host and path) and that every `/services/oauth2/token` request goes to that community host and path, including after a restart. + +| Test | App Config | DPoP | Hybrid | Notes | +|------|-----------|------|--------|-------| +| `testCommunity_Hybrid` | ECA Opaque | No | Yes | Basic login; revoke+refresh works | +| `testCommunity_NoHybrid` | ECA Opaque | No | No | | +| `testCommunity_WithRestart` | ECA Opaque | No | Yes | Session survives a cold restart; revoke+refresh still targets the community | +| `testCommunityJwt_Hybrid` | ECA JWT | No | Yes | JWT access token, Bearer; revoke+refresh works | +| `testCommunityJwt_NoHybrid` | ECA JWT | No | No | | +| `testCommunity_ViaAlternateAuthSurface_WebView` | ECA Opaque | No | Yes | Login via the in-app WebView instead of the default Chrome Custom Tab | +| `testCommunityDPoP_Hybrid` | ECA JWT DPoP | Yes | Yes | | +| `testCommunityDPoP_NoHybrid` | ECA JWT DPoP | Yes | No | | +| `testCommunityDPoP_ViaAlternateAuthSurface_WebView` | ECA JWT DPoP | Yes | Yes | DPoP login via the in-app WebView | +| `testCommunityDPoP_RefreshRotatesTokenAndPreservesBinding` | ECA JWT DPoP RTR | Yes | Yes | Two consecutive revoke+refresh cycles must each rotate the refresh token while the DPoP key thumbprint and binding hold across both | +| `testCommunityDPoP_WithRestart` | ECA JWT DPoP | Yes | Yes | DPoP EC key pair survives process restart (AndroidKeyStore) | +| `testCommunityUpgradeToDPoP_InPlace` | ECA JWT | — | Yes | Bearer → DPoP in-place upgrade, same consumer key | +| `testCommunityDowngradeFromDPoP_InPlace` | ECA JWT | — | Yes | DPoP → Bearer in-place downgrade, same consumer key | +| `testCommunity_BearerLogoutThenReloginWithDPoP` | ECA JWT | — | Yes | Bearer login, full logout (not just revoke), then DPoP login; token type is DPoP and tokens and DPoP key pair are new | +| `testCommunityDPoP_And_RegularAuthDPoP_MultiHost_UniqueTokensAndIsolatedNonces` | ECA JWT DPoP | Yes | Yes | Community user + `regular_auth` ECA JWT DPoP user; unique tokens/keys, independent revoke+refresh and nonce rotation per user/host | + #### RTRLoginTests Tests for ECA configurations with Refresh Token Rotation (RTR) enabled. Verifies that the refresh token rotates on each token refresh cycle. The `assertRevokeAndRefreshWorks` check asserts the refresh token **changes** after a revoke/refresh cycle for RTR apps. The restart regression also observes the final outbound token-request User-Agent and verifies its request-scoped RT, auth-flow, and token-format markers. DPoP+RTR tests live in `DPoPLoginTests`. @@ -372,7 +393,7 @@ L3 takes priority over the resolved domain: even if Welcome Discovery resolves t ### Configuration - **App configs** (`KnownAppConfig`): `ECA_OPAQUE`, `ECA_JWT`, `ECA_OPAQUE_RTR`, `ECA_JWT_RTR`, `ECA_JWT_DPOP`, `ECA_JWT_DPOP_RTR`, `BEACON_OPAQUE`, `BEACON_JWT`, `CA_OPAQUE`, `CA_JWT` -- **Login hosts** (`KnownLoginHostConfig`): `REGULAR_AUTH` (in-app WebView), `ADVANCED_AUTH` (Chrome Custom Tab) +- **Login hosts** (`KnownLoginHostConfig`): `REGULAR_AUTH` (in-app WebView), `ADVANCED_AUTH` (Chrome Custom Tab), `COMMUNITY_AUTH` (Experience Cloud community site; optional — see `CommunityLoginTests`) - **Scope options** (`ScopeSelection`): `EMPTY` (default/boot config scopes), `SUBSET` (all minus `sfap_api`), `ALL` - **Users** (`KnownUserConfig`): `FIRST` through `FIFTH`, assigned per API level diff --git a/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/CommunityLoginTests.kt b/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/CommunityLoginTests.kt new file mode 100644 index 0000000000..673293f49e --- /dev/null +++ b/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/CommunityLoginTests.kt @@ -0,0 +1,571 @@ +/* + * 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.samples.authflowtester + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.LargeTest +import com.salesforce.androidsdk.app.Features.FEATURE_AUTH_TYPE_WEB_SERVER_HYBRID +import com.salesforce.androidsdk.app.Features.FEATURE_AUTH_TYPE_WEB_SERVER_NON_HYBRID +import com.salesforce.androidsdk.app.SalesforceSDKManager +import com.salesforce.androidsdk.auth.HttpAccess +import com.salesforce.samples.authflowtester.testUtility.AuthFlowTest +import com.salesforce.samples.authflowtester.testUtility.KnownAppConfig.ECA_JWT +import com.salesforce.samples.authflowtester.testUtility.KnownAppConfig.ECA_JWT_DPOP +import com.salesforce.samples.authflowtester.testUtility.KnownAppConfig.ECA_JWT_DPOP_RTR +import com.salesforce.samples.authflowtester.testUtility.KnownAppConfig.ECA_OPAQUE +import com.salesforce.samples.authflowtester.testUtility.KnownLoginHostConfig.COMMUNITY_AUTH +import com.salesforce.samples.authflowtester.testUtility.KnownLoginHostConfig.REGULAR_AUTH +import com.salesforce.samples.authflowtester.testUtility.KnownUserConfig +import com.salesforce.samples.authflowtester.testUtility.ScopeSelection.EMPTY +import com.salesforce.samples.authflowtester.testUtility.testConfig +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals +import org.junit.After +import org.junit.Assert.assertTrue +import org.junit.Assume.assumeTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import java.util.concurrent.CopyOnWriteArrayList +import okhttp3.HttpUrl.Companion.toHttpUrl + +/** + * Tests for login flows against a community (Experience Cloud) login host, using the + * `community_auth` login host from `ui_test_config.json`. Follows up on the W-24342940 + * investigation into community DPoP login: community sites route through an Experience Cloud + * front door rather than a plain My Domain host, so these tests exist to confirm the existing + * login/DPoP/multi-user/restart/migration helpers behave the same way against that front door as + * they do against `regular_auth`. + * + * There is no dedicated "community" app config: the community in the AuthFlowTester test org + * (`authflowtesting.msdk.sdb38.com`) is set up (W-24381845) so the existing apps' consumer + * keys/redirect URIs/scopes work unchanged for the community user. Each test below picks the same + * [com.salesforce.samples.authflowtester.testUtility.KnownAppConfig] its equivalent + * `DPoPLoginTests`/`RTRLoginTests` scenario uses, so the app name's own `_jwt`/`_dpop`/`_rtr` + * conventions continue to drive [AppConfig.issuesJwt]/[AppConfig.isDpop]/ + * [AppConfig.expectsRefreshTokenRotation] correctly with no community-specific overrides. + * + * `community_auth` requires a dedicated Experience Cloud site and is not provisioned in every + * environment, so every test here starts with [assumeTrue] and skips cleanly when the host is + * absent from `ui_test_config.json` (see [testConfig.hasLoginHost]). + * + * The `community_auth` login host only provisions a single user ([KnownUserConfig.FIRST]), so + * every call below passes that explicitly rather than relying on the base class's + * [user]/[otherUser] lazy properties (which pick an index up to [KnownUserConfig.FIFTH] depending + * on API level and would go out of bounds against this host's single-user list). + */ +@RunWith(AndroidJUnit4::class) +@LargeTest +class CommunityLoginTests : AuthFlowTest() { + + @Before + fun skipIfCommunityAuthNotConfigured() { + assumeTrue( + "community_auth login host not present in ui_test_config.json; skipping community login tests", + testConfig.hasLoginHost(COMMUNITY_AUTH), + ) + } + + // region Basic Login Tests + + // Login to the community host using the hybrid auth token flow (Bearer); revoke+refresh works. + @Test + fun testCommunity_Hybrid() { + loginAndValidate( + knownAppConfig = ECA_OPAQUE, + knownLoginHostConfig = COMMUNITY_AUTH, + knownUserConfig = KnownUserConfig.FIRST, + ) + assertCommunityRevokeAndRefreshWorks( + expectsRefreshTokenRotation = false, + ) + } + + // Login to the community host without the hybrid auth token; revoke+refresh works. + @Test + fun testCommunity_NoHybrid() { + loginAndValidate( + knownAppConfig = ECA_OPAQUE, + knownLoginHostConfig = COMMUNITY_AUTH, + knownUserConfig = KnownUserConfig.FIRST, + useHybridAuthToken = false, + ) + assertCommunityRevokeAndRefreshWorks( + expectsRefreshTokenRotation = false, + expectedAMarker = FEATURE_AUTH_TYPE_WEB_SERVER_NON_HYBRID, + ) + } + + // Login to the community host with a JWT access token (ECA_JWT, Bearer, no DPoP) using the + // hybrid auth token flow; revoke+refresh works. + @Test + fun testCommunityJwt_Hybrid() { + loginAndValidate( + knownAppConfig = ECA_JWT, + knownLoginHostConfig = COMMUNITY_AUTH, + knownUserConfig = KnownUserConfig.FIRST, + ) + assertCommunityRevokeAndRefreshWorks(expectsRefreshTokenRotation = false, isJwt = true) + } + + // Same as [testCommunityJwt_Hybrid] without the hybrid auth token. + @Test + fun testCommunityJwt_NoHybrid() { + loginAndValidate( + knownAppConfig = ECA_JWT, + knownLoginHostConfig = COMMUNITY_AUTH, + knownUserConfig = KnownUserConfig.FIRST, + useHybridAuthToken = false, + ) + assertCommunityRevokeAndRefreshWorks( + expectsRefreshTokenRotation = false, + expectedAMarker = FEATURE_AUTH_TYPE_WEB_SERVER_NON_HYBRID, + isJwt = true, + ) + } + + // Login to the community host via the in-app WebView instead of the default Chrome Custom Tab + // (forceAdvancedAuthentication = false). Exercises AuthorizationPageObject.tapAllowAfterLogin's + // COMMUNITY_AUTH branch, which only ever fires on this WebView path (the Custom Tab path always + // routes through ChromeCustomTabPageObject, which hardcodes ADVANCED_AUTH regardless of host). + @Test + fun testCommunity_ViaAlternateAuthSurface_WebView() { + loginAndValidate( + knownAppConfig = ECA_OPAQUE, + knownLoginHostConfig = COMMUNITY_AUTH, + knownUserConfig = KnownUserConfig.FIRST, + forceAdvancedAuthentication = false, + ) + assertCommunityRevokeAndRefreshWorks( + expectsRefreshTokenRotation = false, + expectAdvancedAuth = false, + ) + } + + // endregion + + // region DPoP Login Tests + + // Login to the community host with DPoP enabled using the hybrid auth token flow; + // revoke+refresh works and the DPoP nonce rotates. + @Test + fun testCommunityDPoP_Hybrid() { + loginAndValidate( + knownAppConfig = ECA_JWT_DPOP, + knownLoginHostConfig = COMMUNITY_AUTH, + knownUserConfig = KnownUserConfig.FIRST, + useDPoP = true, + ) + assertCommunityRevokeAndRefreshWorks( + expectsRefreshTokenRotation = false, + isDpop = true, + isJwt = true, + ) + } + + // Login to the community host with DPoP enabled, without the hybrid auth token. + @Test + fun testCommunityDPoP_NoHybrid() { + loginAndValidate( + knownAppConfig = ECA_JWT_DPOP, + knownLoginHostConfig = COMMUNITY_AUTH, + knownUserConfig = KnownUserConfig.FIRST, + useHybridAuthToken = false, + useDPoP = true, + ) + assertCommunityRevokeAndRefreshWorks( + expectsRefreshTokenRotation = false, + isDpop = true, + expectedAMarker = FEATURE_AUTH_TYPE_WEB_SERVER_NON_HYBRID, + isJwt = true, + ) + } + + // Login to the community host with DPoP enabled via the in-app WebView surface. + @Test + fun testCommunityDPoP_ViaAlternateAuthSurface_WebView() { + loginAndValidate( + knownAppConfig = ECA_JWT_DPOP, + knownLoginHostConfig = COMMUNITY_AUTH, + knownUserConfig = KnownUserConfig.FIRST, + useDPoP = true, + forceAdvancedAuthentication = false, + ) + assertCommunityRevokeAndRefreshWorks( + expectsRefreshTokenRotation = false, + isDpop = true, + expectAdvancedAuth = false, + isJwt = true, + ) + } + + // ECA JWT DPoP RTR has refresh token rotation (RTR) enabled, so this isn't exercising + // something unique to the community host — it's the scenario (DPoPLoginTests.testECAJwtDPoP_ + // Hybrid's binding check plus RTRLoginTests.testECAOpaqueRtr_Hybrid's rotation check combined) + // that also confirms the DPoP key pair/binding (thumbprint) stays steady across repeated + // rotating refreshes, not just one, now against a community login host. + @Test + fun testCommunityDPoP_RefreshRotatesTokenAndPreservesBinding() { + loginAndValidate( + knownAppConfig = ECA_JWT_DPOP_RTR, + knownLoginHostConfig = COMMUNITY_AUTH, + knownUserConfig = KnownUserConfig.FIRST, + useDPoP = true, + ) + val keyThumbprintAfterLogin = app.getDpopInfo().keyThumbprint + + assertCommunityRevokeAndRefreshWorks( + expectsRefreshTokenRotation = true, + isDpop = true, + isJwt = true, + ) + assertEquals(keyThumbprintAfterLogin, app.getDpopInfo().keyThumbprint) + + assertCommunityRevokeAndRefreshWorks( + expectsRefreshTokenRotation = true, + isDpop = true, + isJwt = true, + ) + assertEquals(keyThumbprintAfterLogin, app.getDpopInfo().keyThumbprint) + } + + // endregion + + // region Restart Tests + + // Login to the community host without DPoP (ECA_OPAQUE), restart the app, and confirm the + // session survives the restart and revoke+refresh still targets the community. + @Test + fun testCommunity_WithRestart() { + loginAndValidate( + knownAppConfig = ECA_OPAQUE, + knownLoginHostConfig = COMMUNITY_AUTH, + knownUserConfig = KnownUserConfig.FIRST, + ) + restartAndValidateUser( + knownAppConfig = ECA_OPAQUE, + knownLoginHostConfig = COMMUNITY_AUTH, + knownUserConfig = KnownUserConfig.FIRST, + ) + assertCommunityRevokeAndRefreshWorks(expectsRefreshTokenRotation = false) + } + + // Login to the community host with DPoP, restart the app, and confirm the DPoP key pair + // (loaded from AndroidKeyStore, not regenerated) and the session both survive the restart. + @Test + fun testCommunityDPoP_WithRestart() { + loginAndValidate( + knownAppConfig = ECA_JWT_DPOP, + knownLoginHostConfig = COMMUNITY_AUTH, + knownUserConfig = KnownUserConfig.FIRST, + useDPoP = true, + ) + val keyThumbprintBeforeRestart = app.getDpopInfo().keyThumbprint + + restartAndValidateUser( + knownAppConfig = ECA_JWT_DPOP, + knownLoginHostConfig = COMMUNITY_AUTH, + knownUserConfig = KnownUserConfig.FIRST, + isDpop = true, + ) + assertEquals(keyThumbprintBeforeRestart, app.getDpopInfo().keyThumbprint) + + assertCommunityRevokeAndRefreshWorks( + expectsRefreshTokenRotation = false, + isDpop = true, + isJwt = true, + ) + } + + // endregion + + // region Migration Tests + + // In-place upgrade: a Bearer session on an (unenforced) ECA against the community host is + // bound to DPoP via the "Upgrade to DPoP" affordance, with no re-consent needed since the + // consumer key/redirect URI/scopes are unchanged. Matches DPoPLoginTests. + // testUpgrade_NonDPoP_InPlace_ToDPoP's app choice. + @Test + fun testCommunityUpgradeToDPoP_InPlace() { + loginAndValidate( + knownAppConfig = ECA_JWT, + knownLoginHostConfig = COMMUNITY_AUTH, + knownUserConfig = KnownUserConfig.FIRST, + useDPoP = false, + ) + upgradeToDPoPAndValidate( + knownAppConfig = ECA_JWT, + knownLoginHostConfig = COMMUNITY_AUTH, + knownUserConfig = KnownUserConfig.FIRST, + ) + } + + // In-place downgrade: a DPoP-bound session on an ECA against the community host is rolled + // back to Bearer via the "Downgrade from DPoP" affordance. Matches DPoPLoginTests. + // testDowngrade_DPoP_InPlace_ToBearer's app choice. + @Test + fun testCommunityDowngradeFromDPoP_InPlace() { + loginAndValidate( + knownAppConfig = ECA_JWT, + knownLoginHostConfig = COMMUNITY_AUTH, + knownUserConfig = KnownUserConfig.FIRST, + useDPoP = true, + ) + downgradeFromDPoPAndValidate( + knownAppConfig = ECA_JWT, + knownLoginHostConfig = COMMUNITY_AUTH, + knownUserConfig = KnownUserConfig.FIRST, + ) + } + + // endregion + + // region Logout Tests + + // Full logout (not just revoke) of a Bearer session followed by a fresh DPoP login: the initial + // login is Bearer and the relogin is DPoP, so a stale token type retained across logout would + // fail the token type assertion. The account is fully removed, and the subsequent login + // establishes new tokens and a new DPoP key pair rather than reusing anything left over. + @Test + fun testCommunity_BearerLogoutThenReloginWithDPoP() { + loginAndValidate( + knownAppConfig = ECA_JWT, + knownLoginHostConfig = COMMUNITY_AUTH, + knownUserConfig = KnownUserConfig.FIRST, + useDPoP = false, + ) + assertEquals("Bearer", app.getDpopInfo().tokenType) + val (accessTokenBeforeLogout, refreshTokenBeforeLogout) = app.getTokens() + val keyThumbprintBeforeLogout = app.getDpopInfo().keyThumbprint + + val sdkManager = SalesforceSDKManager.getInstance() + val communityUsername = testConfig.getUser(COMMUNITY_AUTH, KnownUserConfig.FIRST).username + val communityAccount = sdkManager.userAccountManager.authenticatedUsers + ?.find { it.username == communityUsername } + ?: throw AssertionError("Community user account not found") + sdkManager.logout( + account = sdkManager.userAccountManager.buildAccount(communityAccount), + frontActivity = null, + showLoginPage = false, + ) + waitForUserCount(sdkManager.userAccountManager, expectedCount = 0) + + loginAndValidate( + knownAppConfig = ECA_JWT, + knownLoginHostConfig = COMMUNITY_AUTH, + knownUserConfig = KnownUserConfig.FIRST, + useDPoP = true, + ) + val dpopInfoAfterRelogin = app.getDpopInfo() + assertEquals("DPoP", dpopInfoAfterRelogin.tokenType) + val (accessTokenAfterRelogin, refreshTokenAfterRelogin) = app.getTokens() + assertNotEquals(accessTokenBeforeLogout, accessTokenAfterRelogin) + assertNotEquals(refreshTokenBeforeLogout, refreshTokenAfterRelogin) + assertNotEquals( + "A fresh login after full logout should generate a new DPoP key pair, not reuse the old one", + keyThumbprintBeforeLogout, + dpopInfoAfterRelogin.keyThumbprint, + ) + } + + /** + * Polls the user account manager until the authenticated user count reaches [expectedCount]. + * Used after triggering a logout to avoid a fixed-duration sleep. Mirrors the private helper of + * the same name in MultiUserLoginTests. + */ + private fun waitForUserCount( + userAccountManager: com.salesforce.androidsdk.accounts.UserAccountManager, + expectedCount: Int, + timeoutMs: Long = 10_000L, + ) { + val deadline = System.currentTimeMillis() + timeoutMs + while (System.currentTimeMillis() < deadline) { + val count = userAccountManager.authenticatedUsers?.size ?: 0 + if (count == expectedCount) return + Thread.sleep(250L) + } + val finalCount = userAccountManager.authenticatedUsers?.size ?: 0 + throw AssertionError( + "Timed out after ${timeoutMs}ms waiting for user count to reach $expectedCount (was $finalCount)" + ) + } + + // endregion + + // region Multi-Host Multi-User Tests + + // The community login host only provisions one user, so same-host multi-user isolation can't be + // exercised the way DPoPLoginTests.testECAJwtDPoP_MultiUser_UniqueTokens does. Instead, pair the + // single community user with a second, regular_auth-hosted DPoP user: tokens, DPoP key material, + // and nonces must stay isolated per credential, and switching back and forth must not leak either + // session's auth scheme or markers into the other. + @Test + fun testCommunityDPoP_And_RegularAuthDPoP_MultiHost_UniqueTokensAndIsolatedNonces() { + loginAndValidate( + knownAppConfig = ECA_JWT_DPOP, + knownLoginHostConfig = COMMUNITY_AUTH, + knownUserConfig = KnownUserConfig.FIRST, + useDPoP = true, + ) + val (communityAccessToken, communityRefreshToken) = app.getTokens() + val communityKeyThumbprint = app.getDpopInfo().keyThumbprint + + addOtherUserAndValidate( + knownAppConfig = ECA_JWT_DPOP, + knownLoginHostConfig = REGULAR_AUTH, + useDPoP = true, + ) + val (otherAccessToken, otherRefreshToken) = app.getTokens() + val otherKeyThumbprint = app.getDpopInfo().keyThumbprint + + // Tokens and DPoP key material must be unique across the two users/hosts. + assertNotEquals(communityAccessToken, otherAccessToken) + assertNotEquals(communityRefreshToken, otherRefreshToken) + assertNotEquals(communityKeyThumbprint, otherKeyThumbprint) + + // Switch back to the community user; revoke+refresh must work with its own DPoP nonce. + switchToUserAndValidateUser( + KnownUserConfig.FIRST, + knownLoginHostConfig = COMMUNITY_AUTH, + isDpop = true, + isJwt = true, + ) + app.validateOAuthValues(knownAppConfig = ECA_JWT_DPOP, scopeSelection = EMPTY, knownLoginHostConfig = COMMUNITY_AUTH) + assertCommunityRevokeAndRefreshWorks( + expectsRefreshTokenRotation = false, + isDpop = true, + isMultiUser = true, + isJwt = true, + ) + + // Switch to the regular_auth user; revoke+refresh must work independently with its own + // nonce. + switchToUserAndValidateUser( + otherUser, + knownLoginHostConfig = REGULAR_AUTH, + isDpop = true, + isJwt = true, + ) + app.validateOAuthValues(knownAppConfig = ECA_JWT_DPOP, scopeSelection = EMPTY) + assertRevokeAndRefreshWorks( + expectsRefreshTokenRotation = false, + isDpop = true, + isMultiUser = true, + isJwt = true, + ) + } + + // endregion + + // region Community refresh endpoint helpers + + /** + * Records every request URL sent through the SDK's shared [HttpAccess] so a test can observe + * where a refresh actually went. Wraps the client via `newBuilder()` so the connection pool and + * the SDK's network interceptors are preserved. + */ + private class RecordingHttpAccess( + private val delegate: HttpAccess, + private val requestUrls: MutableList, + ) : HttpAccess(null, delegate.userAgent) { + override fun getOkHttpClient() = delegate.okHttpClient.newBuilder() + .addInterceptor { chain -> + requestUrls.add(chain.request().url.toString()) + chain.proceed(chain.request()) + } + .build() + + override fun hasNetwork() = delegate.hasNetwork() + } + + private val requestUrls = CopyOnWriteArrayList() + private var originalHttpAccess: HttpAccess? = null + + @After + fun restoreHttpAccess() { + originalHttpAccess?.let { HttpAccess.DEFAULT = it } + originalHttpAccess = null + } + + // Idempotent: a restart can replace HttpAccess.DEFAULT, so this is re-run before each refresh. + private fun recordHttpRequests() { + val current = HttpAccess.DEFAULT + if (current is RecordingHttpAccess) return + originalHttpAccess = current + HttpAccess.DEFAULT = RecordingHttpAccess(current, requestUrls) + } + + /** Scheme + host + port + path of a URL, dropping any query, with no trailing slash. */ + private fun urlWithoutQuery(url: String) = + url.toHttpUrl().newBuilder().query(null).build().toString().trimEnd('/') + + /** + * [assertRevokeAndRefreshWorks] against the community host, additionally asserting that the + * stored community URL equals the configured community (host and path) and that the refresh + * request itself went to that community's token endpoint rather than the instance URL. + */ + private fun assertCommunityRevokeAndRefreshWorks( + expectsRefreshTokenRotation: Boolean, + isDpop: Boolean = false, + expectAdvancedAuth: Boolean = true, + isMultiUser: Boolean = false, + expectedAMarker: String? = FEATURE_AUTH_TYPE_WEB_SERVER_HYBRID, + isJwt: Boolean = false, + ) { + val configuredCommunityUrl = urlWithoutQuery(testConfig.getLoginHost(COMMUNITY_AUTH).url) + val storedCommunityUrl = SalesforceSDKManager.getInstance().userAccountManager + .currentUser?.communityUrl + assertEquals( + "Stored community URL should equal the configured community (host and path)", + configuredCommunityUrl, + storedCommunityUrl?.let { urlWithoutQuery(it) }, + ) + + recordHttpRequests() + requestUrls.clear() + assertRevokeAndRefreshWorks( + expectsRefreshTokenRotation = expectsRefreshTokenRotation, + isDpop = isDpop, + knownLoginHostConfig = COMMUNITY_AUTH, + expectAdvancedAuth = expectAdvancedAuth, + isMultiUser = isMultiUser, + expectedAMarker = expectedAMarker, + isJwt = isJwt, + ) + val tokenRequests = requestUrls.filter { it.toHttpUrl().encodedPath.endsWith("/services/oauth2/token") } + assertTrue("Expected at least one token endpoint request during refresh", tokenRequests.isNotEmpty()) + tokenRequests.forEach { + assertEquals( + "Refresh should go to the community token endpoint, not the instance URL", + "$configuredCommunityUrl/services/oauth2/token", + urlWithoutQuery(it), + ) + } + } + + // endregion +} diff --git a/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/pageObjects/AuthFlowTesterPageObject.kt b/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/pageObjects/AuthFlowTesterPageObject.kt index 057e1a2454..85b3d43cc5 100644 --- a/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/pageObjects/AuthFlowTesterPageObject.kt +++ b/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/pageObjects/AuthFlowTesterPageObject.kt @@ -673,7 +673,7 @@ class AuthFlowTesterPageObject(composeTestRule: ComposeTestRule): BasePageObject validateUserAgent(getText(USER_AGENT_CONTENT_DESC), knownLoginHostConfig, usesWelcomeDiscovery, isMultiUser, expectAdvancedAuth, expectedRtMarker = expectedRtMarker, isDpop = isDpop, expectedBMarker = expectedBMarker, expectedLMarker = expectedLMarker, expectedAMarker = expectedAMarker, wasMigrated = wasMigrated, isJwt = isJwt, isBeacon = isBeacon) } - fun validateOAuthValues(knownAppConfig: KnownAppConfig, scopeSelection: ScopeSelection, useHybridAuthToken: Boolean = true, isDpop: Boolean? = null) { + fun validateOAuthValues(knownAppConfig: KnownAppConfig, scopeSelection: ScopeSelection, useHybridAuthToken: Boolean = true, isDpop: Boolean? = null, knownLoginHostConfig: KnownLoginHostConfig = KnownLoginHostConfig.REGULAR_AUTH) { val expected = testConfig.getApp(knownAppConfig) val (accessToken, refreshToken) = getTokens() @@ -699,10 +699,10 @@ class AuthFlowTesterPageObject(composeTestRule: ComposeTestRule): BasePageObject } } - validateSIDs(isDpop = isDpop ?: expected.isDpop, accessToken = accessToken, isJwt = expected.issuesJwt, useHybrid = useHybridAuthToken, scopeList = expected.scopeList) + validateSIDs(isDpop = isDpop ?: expected.isDpop, accessToken = accessToken, isJwt = expected.issuesJwt, useHybrid = useHybridAuthToken, scopeList = expected.scopeList, isCommunity = knownLoginHostConfig == KnownLoginHostConfig.COMMUNITY_AUTH) } - private fun validateSIDs(isDpop: Boolean, accessToken: String, isJwt: Boolean, useHybrid: Boolean, scopeList: List) { + private fun validateSIDs(isDpop: Boolean, accessToken: String, isJwt: Boolean, useHybrid: Boolean, scopeList: List, isCommunity: Boolean = false) { val hasContentScope = scopeList.contains("content") val hasLightningScope = scopeList.contains("lightning") val hasVisualforceScope = scopeList.contains("visualforce") @@ -719,12 +719,23 @@ class AuthFlowTesterPageObject(composeTestRule: ComposeTestRule): BasePageObject val mainSid = getSensitiveValue(MAIN_SID).emptyIfPlaceholder() val uiSid = getSensitiveValue(UI_SID).emptyIfPlaceholder() - assertNotEmpty(contentDomain, shouldNotBeEmpty = hasContentScope && useHybrid, "Content domain") - assertNotEmpty(contentSid, shouldNotBeEmpty = hasContentScope && useHybrid, "Content SID") - assertNotEmpty(lightningDomain, shouldNotBeEmpty = hasLightningScope && useHybrid, "Lightning domain") - assertNotEmpty(lightningSid, shouldNotBeEmpty = hasLightningScope && useHybrid, "Lightning SID") - assertNotEmpty(vfDomain, shouldNotBeEmpty = hasVisualforceScope && useHybrid, "VF domain") - assertNotEmpty(vfSid, shouldNotBeEmpty = hasVisualforceScope && useHybrid, "VF SID") + // Community (Experience Cloud) logins never populate the content/Lightning/Visualforce + // domain+SID pairs, even when the corresponding scope is granted and the hybrid flow is + // used: these fields come straight from the token endpoint's content_domain/content_sid + // (and lightning_*/visualforce_*) response fields (see OAuth2.java), so an empty value + // here reflects what the community's token response actually contains, not an SDK bug. + // Confirmed against the test1 org community (ECA_OPAQUE, hybrid): content/lightning/VF + // domain and SID all empty, while mainSid (the field this flow actually relies on) was + // populated correctly. Matches iOS's prior finding of an empty Lightning domain/SID on + // community hybrid login (see tmp/dpop-community-investigation/). + val expectFeatureDomains = useHybrid && !isCommunity + + assertNotEmpty(contentDomain, shouldNotBeEmpty = hasContentScope && expectFeatureDomains, "Content domain") + assertNotEmpty(contentSid, shouldNotBeEmpty = hasContentScope && expectFeatureDomains, "Content SID") + assertNotEmpty(lightningDomain, shouldNotBeEmpty = hasLightningScope && expectFeatureDomains, "Lightning domain") + assertNotEmpty(lightningSid, shouldNotBeEmpty = hasLightningScope && expectFeatureDomains, "Lightning SID") + assertNotEmpty(vfDomain, shouldNotBeEmpty = hasVisualforceScope && expectFeatureDomains, "VF domain") + assertNotEmpty(vfSid, shouldNotBeEmpty = hasVisualforceScope && expectFeatureDomains, "VF SID") assertNotEmpty(parentSid, shouldNotBeEmpty = isJwt && useHybrid, "Parent SID") assertNotEmpty(uiSid, shouldNotBeEmpty = isDpop && useHybrid, "UI SID") diff --git a/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/pageObjects/AuthorizationPageObject.kt b/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/pageObjects/AuthorizationPageObject.kt index deb720c122..dc6247d9c0 100644 --- a/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/pageObjects/AuthorizationPageObject.kt +++ b/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/pageObjects/AuthorizationPageObject.kt @@ -34,6 +34,7 @@ import androidx.test.uiautomator.UiDevice import androidx.test.uiautomator.UiSelector import com.salesforce.samples.authflowtester.testUtility.KnownLoginHostConfig import com.salesforce.samples.authflowtester.testUtility.KnownLoginHostConfig.ADVANCED_AUTH +import com.salesforce.samples.authflowtester.testUtility.KnownLoginHostConfig.COMMUNITY_AUTH import com.salesforce.samples.authflowtester.testUtility.KnownLoginHostConfig.REGULAR_AUTH import com.salesforce.androidsdk.R as sdkR @@ -68,7 +69,10 @@ class AuthorizationPageObject(composeTestRule: ComposeTestRule) : BasePageObject Thread.sleep(SLEEP_TIME_MS) when(knownLoginHostConfig) { - REGULAR_AUTH -> tapAllowInWebView() + // ChromeCustomTabPageObject.login() always hands this function ADVANCED_AUTH + // regardless of the actual host, so a real REGULAR_AUTH/COMMUNITY_AUTH value here + // only ever arrives via the base LoginPageObject's in-app WebView path. + REGULAR_AUTH, COMMUNITY_AUTH -> tapAllowInWebView() ADVANCED_AUTH -> { dismissSavePasswordDialog() tapAllowInCustomTab() diff --git a/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/pageObjects/ChromeCustomTabPageObject.kt b/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/pageObjects/ChromeCustomTabPageObject.kt index 1d2932493f..868d71618f 100644 --- a/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/pageObjects/ChromeCustomTabPageObject.kt +++ b/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/pageObjects/ChromeCustomTabPageObject.kt @@ -44,6 +44,7 @@ import com.salesforce.androidsdk.app.SalesforceSDKManager import com.salesforce.androidsdk.ui.components.LoginViewTestTags import com.salesforce.samples.authflowtester.testUtility.KnownLoginHostConfig import com.salesforce.samples.authflowtester.testUtility.KnownLoginHostConfig.ADVANCED_AUTH +import com.salesforce.samples.authflowtester.testUtility.KnownLoginHostConfig.COMMUNITY_AUTH import com.salesforce.samples.authflowtester.testUtility.KnownUserConfig import com.salesforce.samples.authflowtester.testUtility.testConfig @@ -67,6 +68,13 @@ private const val FRE_DISMISS_TIMEOUT_MS = 30_000L */ private const val MAX_LOGIN_SUBMISSION_ATTEMPTS = 3 +/** + * Maximum number of retype attempts when a readback shows a credential field empty or mismatched. + * Used only on the community host (see [ChromeCustomTabPageObject.login]), whose single-page login + * form has been observed to re-render shortly after being filled, discarding the typed keystrokes. + */ +private const val MAX_FIELD_RETYPE_ATTEMPTS = 3 + private enum class CredentialField { USERNAME, PASSWORD, @@ -83,9 +91,18 @@ class ChromeCustomTabPageObject(composeTestRule: ComposeTestRule): LoginPageObje private var lastFocusedCredentialField: CredentialField? = null override fun login(knownLoginHostConfig: KnownLoginHostConfig, knownUserConfig: KnownUserConfig) { + waitForCustomTabOnExpectedHost(knownLoginHostConfig) skipGoogleSignIn() val (username, password) = testConfig.getUser(knownLoginHostConfig, knownUserConfig) + // The community host's single-page login form has been observed to re-render shortly after + // being filled, discarding the typed keystrokes (iOS hit the same page-shape issue). Scoped + // to that host rather than applied generically, since every other host's form has not shown + // this behavior. + val verifyTypedFields = knownLoginHostConfig == COMMUNITY_AUTH setUsername(username) + if (verifyTypedFields) { + verifyFieldOrRetype(CredentialField.USERNAME, username) { setUsername(username) } + } // A combined Salesforce My Domain page renders username + password on ONE screen, so the // password field is already present after typing the username. A two-step flow instead // shows a username-only page and needs a Log In tap to advance to the password page. @@ -97,7 +114,15 @@ class ChromeCustomTabPageObject(composeTestRule: ComposeTestRule): LoginPageObje if (!passwordAlreadyVisible) { advanceToPasswordStep() } - submitPassword(password) + submitPassword( + password, + verifyTypedFields = verifyTypedFields, + // Only re-check the username field right before submission on the combined-form case: + // on a two-step form the username field is no longer on screen once advanceToPasswordStep + // has run, and re-typing into whatever EditText(0) resolves to there would corrupt the + // password field instead. + username = username.takeIf { verifyTypedFields && passwordAlreadyVisible }, + ) // Under forced advanced authentication every login completes in the Custom Tab, so the // OAuth approval page is always rendered there regardless of the configured host. AuthorizationPageObject(composeTestRule).tapAllowAfterLogin(ADVANCED_AUTH) @@ -231,6 +256,67 @@ class ChromeCustomTabPageObject(composeTestRule: ComposeTestRule): LoginPageObje lastFocusedCredentialField = CredentialField.PASSWORD } + /** + * Reads back [field]'s current on-screen text via UiAutomator, trying the same selectors + * [setUsername]/[setPassword] use to locate it. Returns null if no matching field is found. + */ + private fun currentFieldText(field: CredentialField): String? { + val resourceId = if (field == CredentialField.PASSWORD) PASSWORD_ID else USERNAME_ID + val byResourceId = device.findObject(UiSelector().resourceId(resourceId)) + if (byResourceId.exists()) return runCatching { byResourceId.text }.getOrNull() + + val byPosition = if (field == CredentialField.PASSWORD) { + combinedFormPasswordField() + } else { + device.findObject(UiSelector().className("android.widget.EditText").instance(0)) + } + if (byPosition.exists()) return runCatching { byPosition.text }.getOrNull() + + return findVisibleChromeInput(expectPassword = field == CredentialField.PASSWORD)?.text?.toString() + } + + /** + * True when [field]'s current readback looks like [expectedValue] was actually accepted. + * Compared by exact text for the username (visible as typed), and by non-empty length match + * for the password, since Chrome's accessibility tree may expose a masked value (e.g. bullet + * characters) rather than the literal characters — a dot count is as much as can be verified + * without reading the real value. Never logs [expectedValue] itself. + */ + private fun fieldLooksFilled(field: CredentialField, expectedValue: String): Boolean { + val currentText = currentFieldText(field) ?: return false + return if (field == CredentialField.PASSWORD) { + currentText.isNotEmpty() && currentText.length == expectedValue.length + } else { + currentText == expectedValue + } + } + + /** + * Community host only (see [login]): the single-page login form has been observed to + * re-render shortly after being filled, discarding the typed keystrokes (iOS hit the same + * page-shape issue against the same page). Reads [field] back via [fieldLooksFilled] and + * retypes it with [retype] when it's empty or doesn't match, up to [MAX_FIELD_RETYPE_ATTEMPTS] + * times, then fails fast with a clear message rather than proceeding to submit a form known to + * still be wrong. Never logs the expected or actual value — only whether a given attempt's + * readback was empty/mismatched. + */ + private fun verifyFieldOrRetype(field: CredentialField, expectedValue: String, retype: () -> Unit) { + repeat(MAX_FIELD_RETYPE_ATTEMPTS) { attempt -> + if (fieldLooksFilled(field, expectedValue)) return + android.util.Log.i( + "ChromeCustomTabPageObject", + "Community $field field empty or mismatched on readback attempt ${attempt + 1}; retyping.", + ) + retype() + } + if (!fieldLooksFilled(field, expectedValue)) { + throw AssertionError( + "Community $field field is still empty or incorrect after " + + "$MAX_FIELD_RETYPE_ATTEMPTS retype attempts", + ) + } + } + /** * Waits for a two-step Salesforce login page to replace the username input with the password * input before typing. The previous page can remain accessible for several seconds after its @@ -253,13 +339,28 @@ class ChromeCustomTabPageObject(composeTestRule: ComposeTestRule): LoginPageObje /** * Enters and submits a password, confirming that Chrome actually left the login form. This * catches both an autofill popup intercepting the tap and an input event dropped by Chrome. + * + * [verifyTypedFields] (community host only — see [login]) reads the password field back after + * typing and retypes it if it's empty or doesn't match. When [username] is also non-null + * (combined-form case only), both fields are read back and retyped once more right before the + * submission tap, since the whole form has been observed to re-render shortly after being + * filled, discarding either field's keystrokes. */ - fun submitPassword(password: String) { + fun submitPassword(password: String, verifyTypedFields: Boolean = false, username: String? = null) { setPassword(password) + if (verifyTypedFields) { + verifyFieldOrRetype(CredentialField.PASSWORD, password) { setPassword(password) } + } // On the combined page the Log In button sits directly below the password field, so the // soft keyboard raised by setPassword covers it. Back closes the IME without leaving the // Custom Tab, making the button visible before the checked tap. dismissKeyboard() + if (username != null) { + // Final check right before tapping Log In: re-verify both fields once more rather than + // just the one most recently typed, since a re-render can clear either. + verifyFieldOrRetype(CredentialField.USERNAME, username) { setUsername(username) } + verifyFieldOrRetype(CredentialField.PASSWORD, password) { setPassword(password) } + } var loginFormWasGone = false tapLoginUntil( failureMessage = "Login form remained on screen after password submission", @@ -472,14 +573,17 @@ class ChromeCustomTabPageObject(composeTestRule: ComposeTestRule): LoginPageObje private fun isLoginButtonVisible(): Boolean = device.findObject(UiSelector().resourceId(LOGIN_BUTTON_ID)).exists() || device.findObject( - UiSelector().className("android.widget.Button").textContains("Log In") + // textMatches regex with the (?i) flag instead of textContains, since UiSelector's + // textContains is case-sensitive and a community ECA's hosted login page is free to + // render the button as "Log in" rather than Chrome's own "Log In". + UiSelector().className("android.widget.Button").textMatches("(?i).*log in.*") ).exists() private fun findLoginButton(fallbackTimeoutMs: Long) = device.findObject(UiSelector().resourceId(LOGIN_BUTTON_ID)) .takeIf { it.waitForExists(QUICK_CHECK_TIMEOUT_MS) } ?: device.findObject( - UiSelector().className("android.widget.Button").textContains("Log In") + UiSelector().className("android.widget.Button").textMatches("(?i).*log in.*") ).takeIf { it.waitForExists(fallbackTimeoutMs) } /** @@ -617,4 +721,56 @@ class ChromeCustomTabPageObject(composeTestRule: ComposeTestRule): LoginPageObje ) return closeButton.waitForExists(timeoutMs) } + + /** + * Waits, before any typing starts, for the Custom Tab to be in front and showing the host the + * test selected. The SDK can fire several VIEW intents in quick succession while it configures + * login options and finally launches the chosen host (see AuthFlowTest.loginAndValidate): the + * default server's own auth config fires first, then re-configuring login options (app/DPoP) + * relaunches the tab, then selecting the target host launches it again. Acting on the first tab + * to appear risks typing into a page that is about to be replaced. Keyed on + * [knownLoginHostConfig] rather than anything community-specific, so this helps every host. + * + * Primary signal is the toolbar's url_bar, which shows the loaded page's host. Chrome does not + * always expose it (observed intermittently on FTL), so when it is missing or empty this falls + * back to the Custom Tab package being present with a login form that has rendered and held + * for two consecutive checks — the same "stable observation" debounce [submitPassword] already + * uses to confirm a form actually left the screen, applied here in reverse to confirm one has + * actually arrived and settled. + */ + private fun waitForCustomTabOnExpectedHost(knownLoginHostConfig: KnownLoginHostConfig) { + val expectedHost = testConfig.getLoginHost(knownLoginHostConfig).url + .substringAfter("://") + .substringBefore("/") + val deadline = System.currentTimeMillis() + TIMEOUT_MS + var formSeenStable = false + while (System.currentTimeMillis() < deadline) { + // Idempotent, and also clears the FRE, which otherwise hides the toolbar/form. + skipGoogleSignIn() + if (!isCustomTabDisplayed()) { + formSeenStable = false + Thread.sleep(QUICK_CHECK_TIMEOUT_MS) + continue + } + val urlBar = device.findObject(UiSelector().resourceId("com.android.chrome:id/url_bar")) + if (urlBar.exists()) { + val urlBarText = runCatching { urlBar.text }.getOrDefault("") + if (urlBarText.contains(expectedHost, ignoreCase = true)) { + return + } + formSeenStable = false + } else { + val formIsVisible = isLoginButtonVisible() || isPasswordStepVisible() + if (formIsVisible && formSeenStable) { + return + } + formSeenStable = formIsVisible + } + Thread.sleep(QUICK_CHECK_TIMEOUT_MS) + } + android.util.Log.w( + "ChromeCustomTabPageObject", + "Timed out waiting for the Custom Tab to show $expectedHost; proceeding anyway.", + ) + } } diff --git a/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/pageObjects/LoginPageObject.kt b/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/pageObjects/LoginPageObject.kt index 6e984b0057..6781f822b4 100644 --- a/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/pageObjects/LoginPageObject.kt +++ b/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/pageObjects/LoginPageObject.kt @@ -55,6 +55,20 @@ internal const val USERNAME_ID = "username" internal const val PASSWORD_ID = "password" internal const val LOGIN_BUTTON_ID = "Login" +/** + * CSS selectors used in place of the fixed element IDs above on community login pages. Confirmed + * via a WebView DOM dump that the community page never renders + * `id="username"`/`id="password"`/`id="Login"` — it uses platform-generated numeric ids (e.g. + * `148:0`) instead, stable for the full page lifetime (no late render, no iframe). This mirrors + * iOS, which never looks up these fields by id either — [LoginPageObject.swift]'s + * `performLogin` locates them by XCUIElement type (`.textField`/`.secureTextField`). Matching that + * approach here with a type-based CSS selector, scoped to [KnownLoginHostConfig.COMMUNITY_AUTH] + * only, since the fixed ids are confirmed to still work for every other known host. + */ +private const val COMMUNITY_USERNAME_SELECTOR = "input[type='text']" +private const val COMMUNITY_PASSWORD_SELECTOR = "input[type='password']" +private const val COMMUNITY_LOGIN_BUTTON_SELECTOR = "button" + /** * Interval between retries of the dev-support dialog tap in [LoginPageObject.openLoginOptions]. * Short enough to re-issue a tap promptly once the dialog's fade-in animation completes. @@ -79,19 +93,75 @@ open class LoginPageObject(composeTestRule: ComposeTestRule): BasePageObject(com } open fun login(knownLoginHostConfig: KnownLoginHostConfig, knownUserConfig: KnownUserConfig) { + activeLoginHostConfig = knownLoginHostConfig val (username, password) = testConfig.getUser(knownLoginHostConfig, knownUserConfig) waitForPageLoad() retryWebAction(timeoutMs = WEBVIEW_ACTION_TIMEOUT_MS) { - onWebView().withElement(findElement(Locator.ID, USERNAME_ID)) + val (locator, value) = usernameLocator() + onWebView().withElement(findElement(locator, value)) .perform(clearElement()) .perform(webKeys(username)) } - tapLogin() + // The community host's single-page login form renders username + password together, so + // the password field is already present right after typing the username. A two-step form + // instead shows a username-only page and needs a Log In tap to advance to the password + // page. Tapping Log In on a combined page submits an empty password, which the server + // rejects and re-renders the form (confirmed via a DOM dump) — + // mirrors ChromeCustomTabPageObject.login's passwordAlreadyVisible check for the same page. + val passwordAlreadyVisible = isPasswordFieldVisible() + if (!passwordAlreadyVisible) { + tapLogin() + } setPassword(password) tapLogin() AuthorizationPageObject(composeTestRule).tapAllowAfterLogin(knownLoginHostConfig) } + /** + * Host config for the login currently in progress. Set by [login]/[welcomeLogin] and + * consulted by [setUsername]/[setPassword]/[tapLogin] to pick an element locator that matches + * the actual markup of that host's login page (see [COMMUNITY_USERNAME_SELECTOR] and friends). + * Left `null` (meaning: use the fixed element ids) when neither entry point has run yet. + */ + private var activeLoginHostConfig: KnownLoginHostConfig? = null + + private fun usernameLocator(): Pair = + if (activeLoginHostConfig == KnownLoginHostConfig.COMMUNITY_AUTH) { + Locator.CSS_SELECTOR to COMMUNITY_USERNAME_SELECTOR + } else { + Locator.ID to USERNAME_ID + } + + private fun passwordLocator(): Pair = + if (activeLoginHostConfig == KnownLoginHostConfig.COMMUNITY_AUTH) { + Locator.CSS_SELECTOR to COMMUNITY_PASSWORD_SELECTOR + } else { + Locator.ID to PASSWORD_ID + } + + private fun loginButtonLocator(): Pair = + if (activeLoginHostConfig == KnownLoginHostConfig.COMMUNITY_AUTH) { + Locator.CSS_SELECTOR to COMMUNITY_LOGIN_BUTTON_SELECTOR + } else { + Locator.ID to LOGIN_BUTTON_ID + } + + /** + * Single, non-retrying check for whether the password field is already present in the + * WebView DOM — i.e. the page is a combined single-page form rather than a two-step flow. + * `withElement(findElement(...))` throws synchronously when the element isn't found, so that + * is treated as "not visible yet" rather than retried; callers that need to wait for a step + * transition should poll separately. + */ + private fun isPasswordFieldVisible(): Boolean = + try { + val (locator, value) = passwordLocator() + onWebView().withElement(findElement(locator, value)) + true + } catch (_: Exception) { + false + } + /** * Exits the login flow when the non-dismissable login-server picker (W-23731759) is in front. * @@ -211,6 +281,7 @@ open class LoginPageObject(composeTestRule: ComposeTestRule): BasePageObject(com * and submit. Mirrors iOS performWelcomeLogin. */ open fun welcomeLogin(knownLoginHostConfig: KnownLoginHostConfig, knownUserConfig: KnownUserConfig) { + activeLoginHostConfig = knownLoginHostConfig val (_, password) = testConfig.getUser(knownLoginHostConfig, knownUserConfig) tapLogin() setPassword(password) @@ -361,7 +432,8 @@ open class LoginPageObject(composeTestRule: ComposeTestRule): BasePageObject(com open fun setUsername(name: String) { retryWebAction { - onWebView().withElement(findElement(Locator.ID, USERNAME_ID)) + val (locator, value) = usernameLocator() + onWebView().withElement(findElement(locator, value)) .perform(clearElement()) .perform(webKeys(name)) } @@ -369,7 +441,8 @@ open class LoginPageObject(composeTestRule: ComposeTestRule): BasePageObject(com open fun setPassword(password: String) { retryWebAction { - onWebView().withElement(findElement(Locator.ID, PASSWORD_ID)) + val (locator, value) = passwordLocator() + onWebView().withElement(findElement(locator, value)) .perform(clearElement()) .perform(webKeys(password)) } @@ -377,7 +450,8 @@ open class LoginPageObject(composeTestRule: ComposeTestRule): BasePageObject(com open fun tapLogin() { retryWebAction { - onWebView().withElement(findElement(Locator.ID, LOGIN_BUTTON_ID)) + val (locator, value) = loginButtonLocator() + onWebView().withElement(findElement(locator, value)) .perform(webClick()) } } diff --git a/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/testUtility/AuthFlowTest.kt b/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/testUtility/AuthFlowTest.kt index bb85b09bfe..d2b552f9a5 100644 --- a/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/testUtility/AuthFlowTest.kt +++ b/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/testUtility/AuthFlowTest.kt @@ -48,6 +48,7 @@ import com.salesforce.samples.authflowtester.pageObjects.LoginOptionsPageObject import com.salesforce.samples.authflowtester.pageObjects.LoginPageObject import com.salesforce.samples.authflowtester.testUtility.KnownAppConfig.CA_OPAQUE import com.salesforce.samples.authflowtester.testUtility.KnownLoginHostConfig.ADVANCED_AUTH +import com.salesforce.samples.authflowtester.testUtility.KnownLoginHostConfig.COMMUNITY_AUTH import com.salesforce.samples.authflowtester.testUtility.KnownLoginHostConfig.REGULAR_AUTH import com.salesforce.samples.authflowtester.testUtility.ScopeSelection.EMPTY import org.junit.After @@ -428,6 +429,9 @@ abstract class AuthFlowTest { useWelcomeDiscovery -> Features.FEATURE_LOGIN_SERVER_WELCOME_DISCOVERY // Pool server (login.salesforce.com, login.*.salesforce.com) registers L1, not L4. useLoginPoolHost -> Features.FEATURE_LOGIN_SERVER_PRODUCTION + // community_auth is a *.my.site.com Experience Cloud site, not a *.my.salesforce.com + // My Domain host, so the SDK classifies it L5 (Other) rather than L4 (My Domain). + knownLoginHostConfig == COMMUNITY_AUTH -> Features.FEATURE_LOGIN_SERVER_OTHER else -> Features.FEATURE_LOGIN_SERVER_MY_DOMAIN } val expectedAMarker = when { @@ -460,7 +464,7 @@ abstract class AuthFlowTest { expectedRtMarker = expectedRtMarker(username), assertUsername = assertUsername, ) - app.validateOAuthValues(knownAppConfig, scopeSelection, useHybridAuthToken = useHybridAuthToken, isDpop = useDPoP) + app.validateOAuthValues(knownAppConfig, scopeSelection, useHybridAuthToken = useHybridAuthToken, isDpop = useDPoP, knownLoginHostConfig = knownLoginHostConfig) app.validateApiRequest() } @@ -518,10 +522,12 @@ abstract class AuthFlowTest { restartApp() val shouldHaveBW = expectAdvancedAuth || knownLoginHostConfig == ADVANCED_AUTH val expectedBMarker = if (shouldHaveBW) Features.FEATURE_BROWSER_LOGIN_FORCE_FLAG else null - val expectedLMarker = if (usesWelcomeDiscovery) { - Features.FEATURE_LOGIN_SERVER_WELCOME_DISCOVERY - } else { - Features.FEATURE_LOGIN_SERVER_MY_DOMAIN + val expectedLMarker = when { + usesWelcomeDiscovery -> Features.FEATURE_LOGIN_SERVER_WELCOME_DISCOVERY + // community_auth is a *.my.site.com Experience Cloud site, not a *.my.salesforce.com + // My Domain host, so the SDK classifies it L5 (Other) rather than L4 (My Domain). + knownLoginHostConfig == COMMUNITY_AUTH -> Features.FEATURE_LOGIN_SERVER_OTHER + else -> Features.FEATURE_LOGIN_SERVER_MY_DOMAIN } val expectedAMarker = when { useWebServerFlow && useHybridAuthToken -> Features.FEATURE_AUTH_TYPE_WEB_SERVER_HYBRID @@ -793,7 +799,7 @@ abstract class AuthFlowTest { isBeacon = appConfig.isBeacon, expectedRtMarker = expectedRtMarker(username), ) - app.validateOAuthValues(knownAppConfig, scopeSelection) + app.validateOAuthValues(knownAppConfig, scopeSelection, knownLoginHostConfig = knownLoginHostConfig) // Assert new tokens work. This revoke forces a normal refresh through the session refresher — // the one path that registers the sticky RT marker. @@ -841,7 +847,7 @@ abstract class AuthFlowTest { ) // The consumer key is unchanged — same app, only the DPoP binding changed. - app.validateOAuthValues(knownAppConfig, scopeSelection = EMPTY, isDpop = true) + app.validateOAuthValues(knownAppConfig, scopeSelection = EMPTY, isDpop = true, knownLoginHostConfig = knownLoginHostConfig) // Assert the newly DPoP-bound tokens work. upgradeToDPoP delegates to the refresh-token // migration path, so the "TM" (token-migration) UA feature flag is legitimately registered @@ -888,7 +894,7 @@ abstract class AuthFlowTest { ) // The consumer key is unchanged — same app, only the DPoP binding changed. - app.validateOAuthValues(knownAppConfig, scopeSelection = EMPTY, isDpop = false) + app.validateOAuthValues(knownAppConfig, scopeSelection = EMPTY, isDpop = false, knownLoginHostConfig = knownLoginHostConfig) // Assert the newly Bearer tokens work with no DPoP proof. downgradeFromDPoP delegates to // the refresh-token migration path, so the "TM" (token-migration) UA feature flag is @@ -940,10 +946,12 @@ abstract class AuthFlowTest { } else { null } - val expectedLMarker = if (useLoginPoolHost) { - Features.FEATURE_LOGIN_SERVER_PRODUCTION - } else { - Features.FEATURE_LOGIN_SERVER_MY_DOMAIN + val expectedLMarker = when { + useLoginPoolHost -> Features.FEATURE_LOGIN_SERVER_PRODUCTION + // community_auth is a *.my.site.com Experience Cloud site, not a *.my.salesforce.com + // My Domain host, so the SDK classifies it L5 (Other) rather than L4 (My Domain). + knownLoginHostConfig == COMMUNITY_AUTH -> Features.FEATURE_LOGIN_SERVER_OTHER + else -> Features.FEATURE_LOGIN_SERVER_MY_DOMAIN } app.validateUserAgent( knownLoginHostConfig = knownLoginHostConfig, @@ -970,7 +978,12 @@ abstract class AuthFlowTest { expectedAMarker: String? = Features.FEATURE_AUTH_TYPE_WEB_SERVER_HYBRID, isJwt: Boolean = false, ) { - app.switchToUser(knownUserConfig) + // Pass the host through: the username/displayName lookup used to find the picker row + // depends on which login host provisioned this user. Every existing caller logs both + // users in from REGULAR_AUTH (the default), where this is a no-op, but community-ECA + // multi-host scenarios pair a COMMUNITY_AUTH user with a REGULAR_AUTH one, so the + // argument must be forwarded or the picker lookup resolves the wrong account. + app.switchToUser(knownUserConfig, knownLoginHostConfig) composeTestRule.waitForIdle() val shouldHaveBW = expectAdvancedAuth || knownLoginHostConfig == ADVANCED_AUTH val expectedBMarker = if (shouldHaveBW) Features.FEATURE_BROWSER_LOGIN_FORCE_FLAG else null diff --git a/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/testUtility/UITestConfig.kt b/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/testUtility/UITestConfig.kt index 4408ece447..772ee45505 100644 --- a/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/testUtility/UITestConfig.kt +++ b/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/testUtility/UITestConfig.kt @@ -50,6 +50,7 @@ enum class KnownUserConfig { enum class KnownLoginHostConfig { REGULAR_AUTH, ADVANCED_AUTH, + COMMUNITY_AUTH, } enum class KnownAppConfig { @@ -90,6 +91,13 @@ data class UITestConfig( (name, _, _) -> name == knownLoginHostConfig.name.toLowerCase(Locale.current) } ?: throw Exception("LoginHost not found.") + // Not every environment provisions every login host (e.g. community_auth requires a + // dedicated Experience Cloud site). Tests that depend on an optional host should skip + // via Assume.assumeTrue(testConfig.hasLoginHost(...)) rather than fail. + fun hasLoginHost(knownLoginHostConfig: KnownLoginHostConfig): Boolean = loginHosts.any { + (name, _, _) -> name == knownLoginHostConfig.name.toLowerCase(Locale.current) + } + fun getUser(knownLoginHostConfig: KnownLoginHostConfig, knownUserConfig: KnownUserConfig): User = getLoginHost(knownLoginHostConfig).users[knownUserConfig.ordinal] diff --git a/native/NativeSampleApps/AuthFlowTester/src/main/res/xml/servers.xml b/native/NativeSampleApps/AuthFlowTester/src/main/res/xml/servers.xml index 5220457565..d0bbc6e68d 100644 --- a/native/NativeSampleApps/AuthFlowTester/src/main/res/xml/servers.xml +++ b/native/NativeSampleApps/AuthFlowTester/src/main/res/xml/servers.xml @@ -4,6 +4,7 @@ + diff --git a/shared/test/ui_test_config.json.sample b/shared/test/ui_test_config.json.sample index 3f59603209..710ebfc789 100644 --- a/shared/test/ui_test_config.json.sample +++ b/shared/test/ui_test_config.json.sample @@ -1,8 +1,9 @@ { + "loginPoolHost": "https://login.your-env.salesforce.com", "loginHosts": [ { "name": "regular_auth", - "url": "https://authflowtestingmsdksdb38.test1.my.pc-rnd.salesforce.com", + "url": "https://your-test-org.my.salesforce.com", "users": [ { "username": "testandroid1@authflowtesting.msdk.sdb38.com", @@ -28,7 +29,7 @@ }, { "name": "advanced_auth", - "url": "https://advauthflowtestingmsdksdb38.test1.my.pc-rnd.salesforce.com", + "url": "https://your-advanced-test-org.my.salesforce.com", "users": [ { "username": "testandroid1@advauthflowtesting.msdk.sdb38.com", @@ -52,6 +53,16 @@ } ] }, + { + "name": "community_auth", + "url": "https://.my.site.com/", + "users": [ + { + "username": "tandroid@community.authflowtesting.msdk.sdb38.com", + "password": "yourPasswordHere" + } + ] + } ], "apps": [ { @@ -115,4 +126,4 @@ "scopes": "api content id lightning refresh_token sfap_api web" } ] -} \ No newline at end of file +}