From 0fea82ff267330172366539d28718761ad1a3428 Mon Sep 17 00:00:00 2001 From: Wolfgang Mathurin Date: Thu, 1 Oct 2026 13:46:42 -0700 Subject: [PATCH 1/7] @W-24342940: Fix DPoP nonce reuse across hosts for community logins Community/Experience Cloud resource servers (identity, REST) never issue their own DPoP-Nonce challenge; they hard-reject a proof with no nonce instead. The client must reuse the nonce most recently issued at the /token endpoint. DPoPNonceCache was keyed strictly by (credentialsIdentifier, host) with no fallback, so a nonce harvested for the login/token host was never available when building a proof for a different resource host, causing community DPoP logins to fail. DPoPNonceCache.get() now falls back to the most recently stored nonce for the credentialsIdentifier (any host) when there is no entry for the exact host. An exact host match always takes precedence. clear()/clearAll() also remove the fallback entry. This mirrors the credential-scoped fallback already used by the iOS implementation. --- .../androidsdk/auth/dpop/DPoPNonceCache.kt | 22 ++++++++-- .../auth/dpop/DPoPNonceCacheTest.kt | 40 +++++++++++++++++++ .../auth/dpop/DPoPRequestDecoratorTest.kt | 38 ++++++++++++++++++ 3 files changed, 97 insertions(+), 3 deletions(-) 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..fdc0655c80 100644 --- a/libs/SalesforceSDK/src/com/salesforce/androidsdk/auth/dpop/DPoPNonceCache.kt +++ b/libs/SalesforceSDK/src/com/salesforce/androidsdk/auth/dpop/DPoPNonceCache.kt @@ -35,28 +35,44 @@ import java.util.concurrent.ConcurrentHashMap * 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]. + * + * Some resource servers (e.g. communities/Experience Cloud sites) never issue their + * own `DPoP-Nonce` challenge; they expect the client to carry forward whatever nonce + * was last issued for that credential, regardless of host. To support that, [get] + * falls back to the most recently stored nonce for [credentialsIdentifier] (any host) + * when there is no entry for the exact `(credentialsIdentifier, host)` pair. An exact + * host match always takes precedence over the 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. 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/dpop/DPoPNonceCacheTest.kt b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/auth/dpop/DPoPNonceCacheTest.kt index 1194c0b090..8d7fced710 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)) } + /* + * W-24342940: some resource servers (e.g. communities/Experience Cloud sites) never issue + * their own DPoP-Nonce challenge; they expect the client to 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..6466fdb103 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, + ), + ) + + /* + * W-24342940: a community/Experience Cloud resource server never issues its own + * DPoP-Nonce challenge; it rejects 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. From a82d54216e5f6a8218a22214ea8099017294f0ae Mon Sep 17 00:00:00 2001 From: Wolfgang Mathurin Date: Thu, 1 Oct 2026 14:06:17 -0700 Subject: [PATCH 2/7] @W-24342940: Clarify DPoPNonceCache doc MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Correct the class/get() KDoc and matching test comments: Salesforce issues DPoP-Nonce only from the token endpoint, never from a resource server (identity, REST) — this isn't specific to communities. Resource-server responses never carry DPoP-Nonce, so the client reuses the latest token-endpoint nonce for the credential on every DPoP call; an exact per-host entry still takes precedence if a server ever does issue one. Logins where the token host differs from the resource hosts (communities, login.* pool servers) rely on this fallback. --- .../androidsdk/auth/dpop/DPoPNonceCache.kt | 29 ++++++++++--------- .../auth/dpop/DPoPNonceCacheTest.kt | 10 +++---- .../auth/dpop/DPoPRequestDecoratorTest.kt | 6 ++-- 3 files changed, 24 insertions(+), 21 deletions(-) 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 fdc0655c80..2ba03dc334 100644 --- a/libs/SalesforceSDK/src/com/salesforce/androidsdk/auth/dpop/DPoPNonceCache.kt +++ b/libs/SalesforceSDK/src/com/salesforce/androidsdk/auth/dpop/DPoPNonceCache.kt @@ -31,19 +31,21 @@ 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. + * RFC 9449 §8 allows a server to supply a `DPoP-Nonce` response header. Salesforce + * only ever issues nonces from the token endpoint — resource-server responses + * (identity, REST) never carry `DPoP-Nonce`, on success or on rejection. This cache + * is keyed by `(credentialsIdentifier, host)` so a per-host nonce, if a server ever + * does supply one, takes precedence. * - * Some resource servers (e.g. communities/Experience Cloud sites) never issue their - * own `DPoP-Nonce` challenge; they expect the client to carry forward whatever nonce - * was last issued for that credential, regardless of host. To support that, [get] - * falls back to the most recently stored nonce for [credentialsIdentifier] (any host) - * when there is no entry for the exact `(credentialsIdentifier, host)` pair. An exact - * host match always takes precedence over the 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. + * Since resource servers don't 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 { @@ -56,7 +58,8 @@ object DPoPNonceCache { /** * 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. Returns null if neither is available. + * [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)] ?: latestByCredential[credentialsIdentifier] 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 8d7fced710..aba717cd28 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 @@ -91,11 +91,11 @@ class DPoPNonceCacheTest { } /* - * W-24342940: some resource servers (e.g. communities/Experience Cloud sites) never issue - * their own DPoP-Nonce challenge; they expect the client to 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. + * W-24342940: Salesforce only issues DPoP-Nonce from the token endpoint; resource + * servers (identity, REST) never 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() { 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 6466fdb103..b23c324b6c 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 @@ -301,9 +301,9 @@ class DPoPRequestDecoratorTest { ) /* - * W-24342940: a community/Experience Cloud resource server never issues its own - * DPoP-Nonce challenge; it rejects a proof carrying no nonce. The client must reuse the - * nonce harvested from the /token host when attaching a proof for a different (resource) + * W-24342940: resource servers (identity, REST) never issue their own DPoP-Nonce + * challenge; they 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 From 72b15bd3aa9be4357392306915570ac686df8ee5 Mon Sep 17 00:00:00 2001 From: Wolfgang Mathurin Date: Thu, 1 Oct 2026 15:04:44 -0700 Subject: [PATCH 3/7] @W-24342940: Update nonce harvest test for credential fallback Pre-seed the pre-redirect host with its own distinct nonce so the cross-host-redirect test still proves the harvest lands under the response host rather than the pre-redirect host. The new credential- wide fallback in DPoPNonceCache.get() made the old assertNull on the pre-redirect host pass vacuously (via the fallback) regardless of which host the harvest actually wrote to. --- ...AuthenticationUtilitiesIntegrationUserTest.kt | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) 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( From 8b3740a20e4241b51416ba3a420267532439fff1 Mon Sep 17 00:00:00 2001 From: Wolfgang Mathurin Date: Fri, 2 Oct 2026 16:58:51 -0700 Subject: [PATCH 4/7] @W-24342940: Address review - drop ticket IDs from test comments, soften nonce KDoc --- .../salesforce/androidsdk/auth/dpop/DPoPNonceCache.kt | 11 ++++++----- .../androidsdk/auth/dpop/DPoPNonceCacheTest.kt | 4 ++-- .../androidsdk/auth/dpop/DPoPRequestDecoratorTest.kt | 4 ++-- 3 files changed, 10 insertions(+), 9 deletions(-) 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 2ba03dc334..4bcd30dd71 100644 --- a/libs/SalesforceSDK/src/com/salesforce/androidsdk/auth/dpop/DPoPNonceCache.kt +++ b/libs/SalesforceSDK/src/com/salesforce/androidsdk/auth/dpop/DPoPNonceCache.kt @@ -32,12 +32,13 @@ import java.util.concurrent.ConcurrentHashMap * Thread-safe in-memory nonce cache for DPoP proof JWTs. * * RFC 9449 §8 allows a server to supply a `DPoP-Nonce` response header. Salesforce - * only ever issues nonces from the token endpoint — resource-server responses - * (identity, REST) never carry `DPoP-Nonce`, on success or on rejection. This cache - * is keyed by `(credentialsIdentifier, host)` so a per-host nonce, if a server ever - * does supply one, takes precedence. + * 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 don't issue their own nonce, [get] falls back to the most + * 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 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 aba717cd28..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 @@ -91,8 +91,8 @@ class DPoPNonceCacheTest { } /* - * W-24342940: Salesforce only issues DPoP-Nonce from the token endpoint; resource - * servers (identity, REST) never issue their own, so the client must carry forward + * 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. 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 b23c324b6c..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 @@ -301,8 +301,8 @@ class DPoPRequestDecoratorTest { ) /* - * W-24342940: resource servers (identity, REST) never issue their own DPoP-Nonce - * challenge; they reject a proof carrying no nonce. The client must reuse the nonce + * 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. */ From c9f9ab6edcc1f440fceeb0803b2a83765a070f42 Mon Sep 17 00:00:00 2001 From: Wolfgang Mathurin Date: Thu, 1 Oct 2026 18:34:14 -0700 Subject: [PATCH 5/7] @W-23075124@: Add community login UI tests --- .../NativeSampleApps/AuthFlowTester/README.md | 20 +- .../authflowtester/CommunityLoginTests.kt | 438 ++++++++++++++++++ .../pageObjects/AuthorizationPageObject.kt | 6 +- .../pageObjects/ChromeCustomTabPageObject.kt | 7 +- .../testUtility/AuthFlowTest.kt | 31 +- .../testUtility/UITestConfig.kt | 8 + shared/test/ui_test_config.json.sample | 17 +- 7 files changed, 511 insertions(+), 16 deletions(-) create mode 100644 native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/CommunityLoginTests.kt diff --git a/native/NativeSampleApps/AuthFlowTester/README.md b/native/NativeSampleApps/AuthFlowTester/README.md index 5af5c4d305..6a4640d836 100644 --- a/native/NativeSampleApps/AuthFlowTester/README.md +++ b/native/NativeSampleApps/AuthFlowTester/README.md @@ -84,6 +84,24 @@ 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. + +| Test | App Config | DPoP | Hybrid | Notes | +|------|-----------|------|--------|-------| +| `testCommunity_Hybrid` | ECA Opaque | No | Yes | Basic login; revoke+refresh works | +| `testCommunity_NoHybrid` | ECA Opaque | 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_LogoutAndRelogin_DPoP` | ECA JWT DPoP | Yes | Yes | Full logout (not just revoke) followed by a fresh login; new tokens and new DPoP key pair | +| `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 +390,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..2cc4daf34e --- /dev/null +++ b/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/CommunityLoginTests.kt @@ -0,0 +1,438 @@ +/* + * 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_NON_HYBRID +import com.salesforce.androidsdk.app.SalesforceSDKManager +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.Assume.assumeTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith + +/** + * 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, + ) + assertRevokeAndRefreshWorks( + expectsRefreshTokenRotation = false, + knownLoginHostConfig = COMMUNITY_AUTH, + ) + } + + // 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, + ) + assertRevokeAndRefreshWorks( + expectsRefreshTokenRotation = false, + knownLoginHostConfig = COMMUNITY_AUTH, + expectedAMarker = FEATURE_AUTH_TYPE_WEB_SERVER_NON_HYBRID, + ) + } + + // 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, + ) + assertRevokeAndRefreshWorks( + expectsRefreshTokenRotation = false, + knownLoginHostConfig = COMMUNITY_AUTH, + 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, + ) + assertRevokeAndRefreshWorks( + expectsRefreshTokenRotation = false, + isDpop = true, + knownLoginHostConfig = COMMUNITY_AUTH, + 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, + ) + assertRevokeAndRefreshWorks( + expectsRefreshTokenRotation = false, + isDpop = true, + knownLoginHostConfig = COMMUNITY_AUTH, + 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, + ) + assertRevokeAndRefreshWorks( + expectsRefreshTokenRotation = false, + isDpop = true, + knownLoginHostConfig = COMMUNITY_AUTH, + 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 + + assertRevokeAndRefreshWorks( + expectsRefreshTokenRotation = true, + isDpop = true, + knownLoginHostConfig = COMMUNITY_AUTH, + isJwt = true, + ) + assertEquals(keyThumbprintAfterLogin, app.getDpopInfo().keyThumbprint) + + assertRevokeAndRefreshWorks( + expectsRefreshTokenRotation = true, + isDpop = true, + knownLoginHostConfig = COMMUNITY_AUTH, + isJwt = true, + ) + assertEquals(keyThumbprintAfterLogin, app.getDpopInfo().keyThumbprint) + } + + // endregion + + // region Restart Tests + + // 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) + + assertRevokeAndRefreshWorks( + expectsRefreshTokenRotation = false, + isDpop = true, + knownLoginHostConfig = COMMUNITY_AUTH, + 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) followed by a fresh DPoP login: the account is fully removed, + // and the subsequent login establishes new tokens and a new DPoP key pair rather than reusing + // anything left over from the previous session. + @Test + fun testCommunity_LogoutAndRelogin_DPoP() { + loginAndValidate( + knownAppConfig = ECA_JWT_DPOP, + knownLoginHostConfig = COMMUNITY_AUTH, + knownUserConfig = KnownUserConfig.FIRST, + useDPoP = true, + ) + 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_DPOP, + knownLoginHostConfig = COMMUNITY_AUTH, + knownUserConfig = KnownUserConfig.FIRST, + useDPoP = true, + ) + 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, + app.getDpopInfo().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) + assertRevokeAndRefreshWorks( + expectsRefreshTokenRotation = false, + isDpop = true, + isMultiUser = true, + knownLoginHostConfig = COMMUNITY_AUTH, + 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 +} 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..b2c68e11bd 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 @@ -472,14 +472,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) } /** 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..4b0bdaa9b2 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 { @@ -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 @@ -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/shared/test/ui_test_config.json.sample b/shared/test/ui_test_config.json.sample index 3f59603209..e4667d07d7 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": "testandroid@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 +} From eb0499ca77d3c4174d6c498337a536b50820b32f Mon Sep 17 00:00:00 2001 From: Wolfgang Mathurin Date: Fri, 2 Oct 2026 17:27:44 -0700 Subject: [PATCH 6/7] @W-23075124@: WIP - community SID exception, servers.xml community host - validateSIDs/validateOAuthValues: add an isCommunity exception so the content/lightning/visualforce domain+SID checks aren't required for community (Experience Cloud) logins. These fields are parsed verbatim from the token endpoint's response (see OAuth2.java) and are genuinely empty for the test1 org's community, confirmed against ECA_OPAQUE hybrid login where mainSid was correctly populated. - Thread knownLoginHostConfig through validateOAuthValues call sites (loginAndValidate, migrateAndValidate, upgradeToDPoPAndValidate, downgradeFromDPoPAndValidate) and one direct CommunityLoginTests call site so the exception applies to every community scenario. - Add a "UITests Community" row to AuthFlowTester's servers.xml with the real community URL, consistent with the existing real UITests/Adv Auth rows already there, so changeServer can select it without any runtime-added-connection test code. - Fix a placeholder username typo in ui_test_config.json.sample (tandroid, not testandroid) to match the real test1 org user. Temporary WIP commit before rebasing onto fix-dpop-nonce-cross-host. --- .../authflowtester/CommunityLoginTests.kt | 2 +- .../pageObjects/AuthFlowTesterPageObject.kt | 29 +++++++++++++------ .../testUtility/AuthFlowTest.kt | 8 ++--- .../src/main/res/xml/servers.xml | 1 + shared/test/ui_test_config.json.sample | 2 +- 5 files changed, 27 insertions(+), 15 deletions(-) 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 index 2cc4daf34e..6a50bced9e 100644 --- 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 @@ -408,7 +408,7 @@ class CommunityLoginTests : AuthFlowTest() { isDpop = true, isJwt = true, ) - app.validateOAuthValues(knownAppConfig = ECA_JWT_DPOP, scopeSelection = EMPTY) + app.validateOAuthValues(knownAppConfig = ECA_JWT_DPOP, scopeSelection = EMPTY, knownLoginHostConfig = COMMUNITY_AUTH) assertRevokeAndRefreshWorks( expectsRefreshTokenRotation = false, isDpop = true, 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/testUtility/AuthFlowTest.kt b/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/testUtility/AuthFlowTest.kt index 4b0bdaa9b2..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 @@ -464,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() } @@ -799,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. @@ -847,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 @@ -894,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 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 e4667d07d7..710ebfc789 100644 --- a/shared/test/ui_test_config.json.sample +++ b/shared/test/ui_test_config.json.sample @@ -58,7 +58,7 @@ "url": "https://.my.site.com/", "users": [ { - "username": "testandroid@community.authflowtesting.msdk.sdb38.com", + "username": "tandroid@community.authflowtesting.msdk.sdb38.com", "password": "yourPasswordHere" } ] From f09c95676ee86ca96391e59dfbba99b4d20e26df Mon Sep 17 00:00:00 2001 From: Wolfgang Mathurin Date: Sat, 3 Oct 2026 20:48:35 -0700 Subject: [PATCH 7/7] @W-23075124@: Address review - community parity tests, Bearer-then-DPoP relogin, refresh-endpoint assertions Add testCommunity_WithRestart, testCommunityJwt_Hybrid and testCommunityJwt_NoHybrid. Replace testCommunity_LogoutAndRelogin_DPoP with testCommunity_BearerLogoutThenReloginWithDPoP. Assert the stored community URL and that token refresh requests target the configured community host and path. Harden the community login page objects (readback-and-retype, CSS selectors). Update the AuthFlowTester README. --- .../NativeSampleApps/AuthFlowTester/README.md | 7 +- .../authflowtester/CommunityLoginTests.kt | 187 +++++++++++++++--- .../pageObjects/ChromeCustomTabPageObject.kt | 157 ++++++++++++++- .../pageObjects/LoginPageObject.kt | 84 +++++++- 4 files changed, 399 insertions(+), 36 deletions(-) diff --git a/native/NativeSampleApps/AuthFlowTester/README.md b/native/NativeSampleApps/AuthFlowTester/README.md index 6a4640d836..c27a71e606 100644 --- a/native/NativeSampleApps/AuthFlowTester/README.md +++ b/native/NativeSampleApps/AuthFlowTester/README.md @@ -85,12 +85,15 @@ All DPoP tests live here — basic login, RTR, multi-user, migration, server enf | `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. +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 | | @@ -99,7 +102,7 @@ Tests for login against a community (Experience Cloud) login host, using the `co | `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_LogoutAndRelogin_DPoP` | ECA JWT DPoP | Yes | Yes | Full logout (not just revoke) followed by a fresh login; new tokens and new DPoP key pair | +| `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 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 index 6a50bced9e..673293f49e 100644 --- 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 @@ -28,8 +28,10 @@ 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 @@ -42,10 +44,14 @@ 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 @@ -94,9 +100,8 @@ class CommunityLoginTests : AuthFlowTest() { knownLoginHostConfig = COMMUNITY_AUTH, knownUserConfig = KnownUserConfig.FIRST, ) - assertRevokeAndRefreshWorks( + assertCommunityRevokeAndRefreshWorks( expectsRefreshTokenRotation = false, - knownLoginHostConfig = COMMUNITY_AUTH, ) } @@ -109,10 +114,37 @@ class CommunityLoginTests : AuthFlowTest() { knownUserConfig = KnownUserConfig.FIRST, useHybridAuthToken = false, ) - assertRevokeAndRefreshWorks( + 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, ) } @@ -128,9 +160,8 @@ class CommunityLoginTests : AuthFlowTest() { knownUserConfig = KnownUserConfig.FIRST, forceAdvancedAuthentication = false, ) - assertRevokeAndRefreshWorks( + assertCommunityRevokeAndRefreshWorks( expectsRefreshTokenRotation = false, - knownLoginHostConfig = COMMUNITY_AUTH, expectAdvancedAuth = false, ) } @@ -149,10 +180,9 @@ class CommunityLoginTests : AuthFlowTest() { knownUserConfig = KnownUserConfig.FIRST, useDPoP = true, ) - assertRevokeAndRefreshWorks( + assertCommunityRevokeAndRefreshWorks( expectsRefreshTokenRotation = false, isDpop = true, - knownLoginHostConfig = COMMUNITY_AUTH, isJwt = true, ) } @@ -167,10 +197,9 @@ class CommunityLoginTests : AuthFlowTest() { useHybridAuthToken = false, useDPoP = true, ) - assertRevokeAndRefreshWorks( + assertCommunityRevokeAndRefreshWorks( expectsRefreshTokenRotation = false, isDpop = true, - knownLoginHostConfig = COMMUNITY_AUTH, expectedAMarker = FEATURE_AUTH_TYPE_WEB_SERVER_NON_HYBRID, isJwt = true, ) @@ -186,10 +215,9 @@ class CommunityLoginTests : AuthFlowTest() { useDPoP = true, forceAdvancedAuthentication = false, ) - assertRevokeAndRefreshWorks( + assertCommunityRevokeAndRefreshWorks( expectsRefreshTokenRotation = false, isDpop = true, - knownLoginHostConfig = COMMUNITY_AUTH, expectAdvancedAuth = false, isJwt = true, ) @@ -210,18 +238,16 @@ class CommunityLoginTests : AuthFlowTest() { ) val keyThumbprintAfterLogin = app.getDpopInfo().keyThumbprint - assertRevokeAndRefreshWorks( + assertCommunityRevokeAndRefreshWorks( expectsRefreshTokenRotation = true, isDpop = true, - knownLoginHostConfig = COMMUNITY_AUTH, isJwt = true, ) assertEquals(keyThumbprintAfterLogin, app.getDpopInfo().keyThumbprint) - assertRevokeAndRefreshWorks( + assertCommunityRevokeAndRefreshWorks( expectsRefreshTokenRotation = true, isDpop = true, - knownLoginHostConfig = COMMUNITY_AUTH, isJwt = true, ) assertEquals(keyThumbprintAfterLogin, app.getDpopInfo().keyThumbprint) @@ -231,6 +257,23 @@ class CommunityLoginTests : AuthFlowTest() { // 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 @@ -251,10 +294,9 @@ class CommunityLoginTests : AuthFlowTest() { ) assertEquals(keyThumbprintBeforeRestart, app.getDpopInfo().keyThumbprint) - assertRevokeAndRefreshWorks( + assertCommunityRevokeAndRefreshWorks( expectsRefreshTokenRotation = false, isDpop = true, - knownLoginHostConfig = COMMUNITY_AUTH, isJwt = true, ) } @@ -304,17 +346,19 @@ class CommunityLoginTests : AuthFlowTest() { // region Logout Tests - // Full logout (not just revoke) followed by a fresh DPoP login: the account is fully removed, - // and the subsequent login establishes new tokens and a new DPoP key pair rather than reusing - // anything left over from the previous session. + // 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_LogoutAndRelogin_DPoP() { + fun testCommunity_BearerLogoutThenReloginWithDPoP() { loginAndValidate( - knownAppConfig = ECA_JWT_DPOP, + knownAppConfig = ECA_JWT, knownLoginHostConfig = COMMUNITY_AUTH, knownUserConfig = KnownUserConfig.FIRST, - useDPoP = true, + useDPoP = false, ) + assertEquals("Bearer", app.getDpopInfo().tokenType) val (accessTokenBeforeLogout, refreshTokenBeforeLogout) = app.getTokens() val keyThumbprintBeforeLogout = app.getDpopInfo().keyThumbprint @@ -331,18 +375,20 @@ class CommunityLoginTests : AuthFlowTest() { waitForUserCount(sdkManager.userAccountManager, expectedCount = 0) loginAndValidate( - knownAppConfig = ECA_JWT_DPOP, + 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, - app.getDpopInfo().keyThumbprint, + dpopInfoAfterRelogin.keyThumbprint, ) } @@ -409,11 +455,10 @@ class CommunityLoginTests : AuthFlowTest() { isJwt = true, ) app.validateOAuthValues(knownAppConfig = ECA_JWT_DPOP, scopeSelection = EMPTY, knownLoginHostConfig = COMMUNITY_AUTH) - assertRevokeAndRefreshWorks( + assertCommunityRevokeAndRefreshWorks( expectsRefreshTokenRotation = false, isDpop = true, isMultiUser = true, - knownLoginHostConfig = COMMUNITY_AUTH, isJwt = true, ) @@ -435,4 +480,92 @@ class CommunityLoginTests : AuthFlowTest() { } // 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/ChromeCustomTabPageObject.kt b/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/pageObjects/ChromeCustomTabPageObject.kt index b2c68e11bd..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", @@ -620,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()) } }