diff --git a/libs/SalesforceSDK/src/com/salesforce/androidsdk/auth/dpop/DPoPNonceCache.kt b/libs/SalesforceSDK/src/com/salesforce/androidsdk/auth/dpop/DPoPNonceCache.kt index a250ea51a6..4bcd30dd71 100644 --- a/libs/SalesforceSDK/src/com/salesforce/androidsdk/auth/dpop/DPoPNonceCache.kt +++ b/libs/SalesforceSDK/src/com/salesforce/androidsdk/auth/dpop/DPoPNonceCache.kt @@ -31,32 +31,52 @@ import java.util.concurrent.ConcurrentHashMap /** * Thread-safe in-memory nonce cache for DPoP proof JWTs. * - * RFC 9449 §8 allows the AS/RS to supply a `DPoP-Nonce` response header. The - * client must echo that value in the `nonce` claim of its next DPoP proof for the - * same endpoint. This cache is keyed by `(credentialsIdentifier, host)` so that - * the AS nonce (login host) and RS nonce (instance host) never overwrite each other. - * This matches the per-host isolation used by the iOS implementation, while also - * ensuring per-user isolation consistent with [DPoPKeyManager]. + * RFC 9449 §8 allows a server to supply a `DPoP-Nonce` response header. Salesforce + * issues nonces from the token endpoint; resource-server responses (identity, REST) + * are not expected to carry one, though the SDK still harvests a `DPoP-Nonce` from + * any response that does (e.g. the userinfo nonce-challenge retry in + * `AuthenticationUtilities`). This cache is keyed by `(credentialsIdentifier, host)` + * so a nonce supplied by a specific host takes precedence for that host. + * + * Since resource servers aren't expected to issue their own nonce, [get] falls back to the most + * recently stored nonce for [credentialsIdentifier] (any host) when there is no entry + * for the exact `(credentialsIdentifier, host)` pair, so the client reuses the latest + * token-endpoint nonce on every DPoP call for that credential. An exact host match + * always takes precedence over the fallback. Logins where the token host differs from + * the resource hosts (e.g. communities, login.* pool servers) rely on this fallback. + * This matches the credential-scoped fallback used by the iOS implementation + * (`DPoPNonceCache.swift`'s `nonce(htu:scope:) ?? latest(forScope:)`), layered on top + * of Android's existing per-host isolation. */ object DPoPNonceCache { private val cache = ConcurrentHashMap() + private val latestByCredential = ConcurrentHashMap() private fun cacheKey(credentialsIdentifier: String, host: String) = "$credentialsIdentifier|$host" + /** + * Returns the nonce cached for the exact `(credentialsIdentifier, host)` pair, or, + * if none was ever stored for that host, the most recently stored nonce for + * [credentialsIdentifier] on any host — i.e. the latest nonce issued at the token + * endpoint for this credential. Returns null if neither is available. + */ fun get(credentialsIdentifier: String, host: String): String? = - cache[cacheKey(credentialsIdentifier, host)] + cache[cacheKey(credentialsIdentifier, host)] ?: latestByCredential[credentialsIdentifier] fun store(credentialsIdentifier: String, host: String, nonce: String) { cache[cacheKey(credentialsIdentifier, host)] = nonce + latestByCredential[credentialsIdentifier] = nonce } fun clear(credentialsIdentifier: String) { cache.keys.removeAll { it.startsWith("$credentialsIdentifier|") } + latestByCredential.remove(credentialsIdentifier) } fun clearAll() { cache.clear() + latestByCredential.clear() } } diff --git a/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/auth/AuthenticationUtilitiesIntegrationUserTest.kt b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/auth/AuthenticationUtilitiesIntegrationUserTest.kt index 6c27c4f2d7..50c31bfbc1 100644 --- a/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/auth/AuthenticationUtilitiesIntegrationUserTest.kt +++ b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/auth/AuthenticationUtilitiesIntegrationUserTest.kt @@ -208,11 +208,22 @@ class AuthenticationUtilitiesIntegrationUserTest { * instance), the harvested nonce must be stored under the response's * host, not the pre-redirect request's host — otherwise a retry proof * built for the response's host never finds it. + * + * The pre-redirect host is pre-seeded with its own, distinct nonce so + * this is a real test of per-host storage rather than of + * [DPoPNonceCache.get]'s cross-host fallback (which would otherwise mask + * a regression here, since the fallback returns the credential's latest + * nonce for any host with no exact entry — including the pre-redirect + * host — and `instance-nonce` would satisfy both assertions below even + * if the harvest wrongly landed on the pre-redirect host). The exact + * `(credentialsIdentifier, "instance.test")` entry staying at the + * pre-seeded value proves the harvest never overwrote it. */ @Test fun test_fetchIsSalesforceIntegrationUser_crossHostRedirect_harvestsNonceUnderResponseHost() { generateOrLoadKeyPair(alias) clear(credentialsIdentifier) + store(credentialsIdentifier, "instance.test", "pre-redirect-nonce") httpAccess.enqueueIntegrationUserSuccess( isIntegrationUser = false, headers = mapOf("DPoP-Nonce" to "instance-nonce"), @@ -223,8 +234,9 @@ class AuthenticationUtilitiesIntegrationUserTest { fetchIsSalesforceIntegrationUser(tokenResponse, "https://login.salesforce.com") - assertNull( - "Nonce must not be stored under the pre-redirect request host", + assertEquals( + "Pre-redirect request host's own nonce must not be overwritten by the response host's harvest", + "pre-redirect-nonce", get(credentialsIdentifier, "instance.test") ) assertEquals( diff --git a/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/auth/dpop/DPoPNonceCacheTest.kt b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/auth/dpop/DPoPNonceCacheTest.kt index 1194c0b090..283ec19937 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,47 @@ class DPoPNonceCacheTest { assertEquals("RS nonce must be retrievable by instance host", rsNonce, DPoPNonceCache.get(id, instanceHost)) } + /* + * Salesforce issues DPoP-Nonce from the token endpoint; resource servers (identity, + * REST) are not expected to issue their own, so the client must carry forward + * the nonce most recently issued for that credential, regardless of host. When no + * nonce was ever stored for the exact (credentialsIdentifier, host) pair, get() must + * fall back to the most recently stored nonce for that credential on any host. + */ + @Test + fun test_givenNonceStoredForOtherHost_whenGetForUnseenHost_thenFallbackNonceReturned() { + val tokenHost = "community.my.site.com" + val resourceHost = "community.my.salesforce.com" + DPoPNonceCache.store(id, tokenHost, "token-host-nonce") + assertEquals("token-host-nonce", DPoPNonceCache.get(id, resourceHost)) + } + + @Test + fun test_givenExactHostMatch_whenFallbackAlsoAvailable_thenExactMatchTakesPrecedence() { + // The exact-host nonce is written first so the most recent (fallback) nonce differs from it. + DPoPNonceCache.store(id, instanceHost, "exact-nonce") + DPoPNonceCache.store(id, loginHost, "fallback-nonce") + assertEquals("exact-nonce", DPoPNonceCache.get(id, instanceHost)) + } + + @Test + fun test_givenFallbackNonceStored_whenClear_thenFallbackIsAlsoRemoved() { + DPoPNonceCache.store(id, loginHost, "token-host-nonce") + DPoPNonceCache.clear(id) + assertNull(DPoPNonceCache.get(id, "some.other.host.salesforce.com")) + } + + @Test + fun test_givenTwoDifferentIdentifiers_whenOneHasFallbackOnly_thenOtherIdentifierDoesNotLeak() { + val id2 = "user2" + DPoPNonceCache.store(id, loginHost, "user1-nonce") + // id2 never stores anything, including for loginHost or any other host. + assertNull(DPoPNonceCache.get(id2, loginHost)) + assertNull(DPoPNonceCache.get(id2, instanceHost)) + // id's fallback must still resolve correctly for a host it never used directly. + assertEquals("user1-nonce", DPoPNonceCache.get(id, instanceHost)) + } + @Test fun test_givenConcurrentWrites_whenMultipleThreads_thenNoRace() { val threadCount = 20 diff --git a/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/auth/dpop/DPoPRequestDecoratorTest.kt b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/auth/dpop/DPoPRequestDecoratorTest.kt index 97eea51d5b..8935f84e58 100644 --- a/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/auth/dpop/DPoPRequestDecoratorTest.kt +++ b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/auth/dpop/DPoPRequestDecoratorTest.kt @@ -26,6 +26,7 @@ */ package com.salesforce.androidsdk.auth.dpop +import android.util.Base64 import androidx.test.ext.junit.runners.AndroidJUnit4 import com.salesforce.androidsdk.accounts.UserAccount import com.salesforce.androidsdk.accounts.UserAccountBuilder @@ -34,6 +35,7 @@ import okhttp3.Protocol import okhttp3.Request import okhttp3.Response import okhttp3.ResponseBody.Companion.toResponseBody +import org.json.JSONObject import org.junit.After import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse @@ -291,6 +293,42 @@ class DPoPRequestDecoratorTest { } } + private fun decodeJson(segment: String): JSONObject = JSONObject( + String( + Base64.decode(segment, Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP), + Charsets.UTF_8, + ), + ) + + /* + * Community logins: the token host differs from the resource hosts (identity, REST), + * which reject a proof carrying no nonce. The client must reuse the nonce + * harvested from the /token host when attaching a proof for a different (resource) + * host under the same credential. + */ + @Test + fun applyAuthHeaders_dpopAccount_nonceHarvestedOnDifferentHost_fallsBackToLatestNonce() { + DPoPNonceCache.clearAll() + try { + seedKeyPair(testScope) + DPoPNonceCache.store(testScope, "community.my.site.com", "token-host-nonce") + + val builder = Request.Builder().url("https://community.my.salesforce.com/id/orgId/userId").get() + DPoPRequestDecorator.applyAuthHeaders(builder, userAccount(tokenType = "DPoP")) + + val proof = builder.build().header(DPoPRequestDecorator.DPOP_HEADER) + assertNotNull("Expected a DPoP proof header", proof) + val payload = decodeJson(proof!!.split(".")[1]) + assertEquals( + "Expected the /token-host nonce to be reused for the resource host", + "token-host-nonce", + payload.getString("nonce"), + ) + } finally { + DPoPNonceCache.clearAll() + } + } + @Test fun applyAuthHeaders_lowercaseDPoPTokenType_stampsDPoPSchemeNotBearer() { // Regression for W-24027018: server returns lowercase "dpop" in token refresh responses.