Skip to content
Draft
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() {
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,46 @@ class DPoPNonceCacheTest {
assertEquals("RS nonce must be retrievable by instance host", rsNonce, DPoPNonceCache.get(id, instanceHost))
}

/*
* Salesforce issues DPoP-Nonce from the token endpoint; resource servers (identity,
* REST) are not expected to issue their own, so the client must carry forward
* the nonce most recently issued for that credential, regardless of host. When no
* nonce was ever stored for the exact (credentialsIdentifier, host) pair, get() must
* fall back to the most recently stored nonce for that credential on any host.
*/
@Test
fun test_givenNonceStoredForOtherHost_whenGetForUnseenHost_thenFallbackNonceReturned() {
val tokenHost = "community.my.site.com"
val resourceHost = "community.my.salesforce.com"
DPoPNonceCache.store(id, tokenHost, "token-host-nonce")
assertEquals("token-host-nonce", DPoPNonceCache.get(id, resourceHost))
}

@Test
fun test_givenExactHostMatch_whenFallbackAlsoAvailable_thenExactMatchTakesPrecedence() {
DPoPNonceCache.store(id, loginHost, "fallback-nonce")
DPoPNonceCache.store(id, instanceHost, "exact-nonce")
assertEquals("exact-nonce", DPoPNonceCache.get(id, instanceHost))
}

@Test
fun test_givenFallbackNonceStored_whenClear_thenFallbackIsAlsoRemoved() {
DPoPNonceCache.store(id, loginHost, "token-host-nonce")
DPoPNonceCache.clear(id)
assertNull(DPoPNonceCache.get(id, "some.other.host.salesforce.com"))
}

@Test
fun test_givenTwoDifferentIdentifiers_whenOneHasFallbackOnly_thenOtherIdentifierDoesNotLeak() {
val id2 = "user2"
DPoPNonceCache.store(id, loginHost, "user1-nonce")
// id2 never stores anything, including for loginHost or any other host.
assertNull(DPoPNonceCache.get(id2, loginHost))
assertNull(DPoPNonceCache.get(id2, instanceHost))
// id's fallback must still resolve correctly for a host it never used directly.
assertEquals("user1-nonce", DPoPNonceCache.get(id, instanceHost))
}

@Test
fun test_givenConcurrentWrites_whenMultipleThreads_thenNoRace() {
val threadCount = 20
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
23 changes: 22 additions & 1 deletion native/NativeSampleApps/AuthFlowTester/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,27 @@ All DPoP tests live here — basic login, RTR, multi-user, migration, server enf
| `testECAJwtDPoPRtr_RevokedBeforeManyRequests_RotatesAndPreservesBinding` | ECA JWT DPoP RTR | — | Twenty concurrent 401s share one refresh; access and refresh tokens rotate while DPoP binding remains valid |
| `testECAJwtDPoPRtr_AfterRestart_ManyRequestNonceRecoverySucceeds` | ECA JWT DPoP RTR | — | Cold nonce cache plus concurrent refresh; at most one nonce challenge/retry; key binding survives restart |

#### CommunityLoginTests
Tests for login against a community (Experience Cloud) login host, using the `community_auth` login host and the existing ECAs (no dedicated community app config — the community in the AuthFlowTester test org is set up so the existing apps' consumer keys/redirect URIs work unchanged for the community user; see `CommunityLoginTests`' class doc for the app chosen per scenario). Follows up on the DPoP + community investigation (W-24342940): community sites route through an Experience Cloud front door rather than a plain My Domain host, so these tests confirm the existing login/DPoP/multi-user/restart/migration helpers behave the same way against that front door as they do against `regular_auth`. `community_auth` requires a dedicated Experience Cloud site and is not provisioned in every environment — every test skips cleanly (`Assume.assumeTrue`) when the host is absent from `ui_test_config.json`. The `community_auth` login host provisions only one user, so cross-host multi-user coverage pairs it with a `regular_auth` DPoP user instead of a second `community_auth` user. Each refresh-based test also asserts that the stored community URL equals the configured `community_auth` URL (host and path) and that every `/services/oauth2/token` request goes to that community host and path, including after a restart.

| Test | App Config | DPoP | Hybrid | Notes |
|------|-----------|------|--------|-------|
| `testCommunity_Hybrid` | ECA Opaque | No | Yes | Basic login; revoke+refresh works |
| `testCommunity_NoHybrid` | ECA Opaque | No | No | |
| `testCommunity_WithRestart` | ECA Opaque | No | Yes | Session survives a cold restart; revoke+refresh still targets the community |
| `testCommunityJwt_Hybrid` | ECA JWT | No | Yes | JWT access token, Bearer; revoke+refresh works |
| `testCommunityJwt_NoHybrid` | ECA JWT | No | No | |
| `testCommunity_ViaAlternateAuthSurface_WebView` | ECA Opaque | No | Yes | Login via the in-app WebView instead of the default Chrome Custom Tab |
| `testCommunityDPoP_Hybrid` | ECA JWT DPoP | Yes | Yes | |
| `testCommunityDPoP_NoHybrid` | ECA JWT DPoP | Yes | No | |
| `testCommunityDPoP_ViaAlternateAuthSurface_WebView` | ECA JWT DPoP | Yes | Yes | DPoP login via the in-app WebView |
| `testCommunityDPoP_RefreshRotatesTokenAndPreservesBinding` | ECA JWT DPoP RTR | Yes | Yes | Two consecutive revoke+refresh cycles must each rotate the refresh token while the DPoP key thumbprint and binding hold across both |
| `testCommunityDPoP_WithRestart` | ECA JWT DPoP | Yes | Yes | DPoP EC key pair survives process restart (AndroidKeyStore) |
| `testCommunityUpgradeToDPoP_InPlace` | ECA JWT | — | Yes | Bearer → DPoP in-place upgrade, same consumer key |
| `testCommunityDowngradeFromDPoP_InPlace` | ECA JWT | — | Yes | DPoP → Bearer in-place downgrade, same consumer key |
| `testCommunity_BearerLogoutThenReloginWithDPoP` | ECA JWT | — | Yes | Bearer login, full logout (not just revoke), then DPoP login; token type is DPoP and tokens and DPoP key pair are new |
| `testCommunityDPoP_And_RegularAuthDPoP_MultiHost_UniqueTokensAndIsolatedNonces` | ECA JWT DPoP | Yes | Yes | Community user + `regular_auth` ECA JWT DPoP user; unique tokens/keys, independent revoke+refresh and nonce rotation per user/host |

#### RTRLoginTests
Tests for ECA configurations with Refresh Token Rotation (RTR) enabled. Verifies that the refresh token rotates on each token refresh cycle. The `assertRevokeAndRefreshWorks` check asserts the refresh token **changes** after a revoke/refresh cycle for RTR apps. The restart regression also observes the final outbound token-request User-Agent and verifies its request-scoped RT, auth-flow, and token-format markers. DPoP+RTR tests live in `DPoPLoginTests`.

Expand Down Expand Up @@ -372,7 +393,7 @@ L3 takes priority over the resolved domain: even if Welcome Discovery resolves t
### Configuration

- **App configs** (`KnownAppConfig`): `ECA_OPAQUE`, `ECA_JWT`, `ECA_OPAQUE_RTR`, `ECA_JWT_RTR`, `ECA_JWT_DPOP`, `ECA_JWT_DPOP_RTR`, `BEACON_OPAQUE`, `BEACON_JWT`, `CA_OPAQUE`, `CA_JWT`
- **Login hosts** (`KnownLoginHostConfig`): `REGULAR_AUTH` (in-app WebView), `ADVANCED_AUTH` (Chrome Custom Tab)
- **Login hosts** (`KnownLoginHostConfig`): `REGULAR_AUTH` (in-app WebView), `ADVANCED_AUTH` (Chrome Custom Tab), `COMMUNITY_AUTH` (Experience Cloud community site; optional — see `CommunityLoginTests`)
- **Scope options** (`ScopeSelection`): `EMPTY` (default/boot config scopes), `SUBSET` (all minus `sfap_api`), `ALL`
- **Users** (`KnownUserConfig`): `FIRST` through `FIFTH`, assigned per API level

Expand Down
Loading
Loading