Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, String>()
private val latestByCredential = ConcurrentHashMap<String, String>()

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() {
Comment thread
wmathurin marked this conversation as resolved.
cache.clear()
latestByCredential.clear()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand All @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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.
Expand Down
Loading