Conversation
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.
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.
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.
JohnsonEricAtSalesforce
left a comment
There was a problem hiding this comment.
Verified the fix directly against the code, not just the PR description: call sites, thread-safety, clear()/clearAll() symmetry, and the credential-collision scenario all check out — couldn't construct a case where the fallback returns a wrong nonce, only an absent one (handled by existing retry logic). Independently confirmed the PR 3038 orthogonality claim and the AuthenticationUtilitiesIntegrationUserTest.kt change; both hold up. Also checked the PR's regression claim against the actual DPoPLoginTests file — the three named failing tests are real and match; the "21 non-community tests" count doesn't (file has 18) — see open question below. CI is green.
Two minor items and two open questions below. This touches credential handling, which may still warrant a second human review before merge.
Requested Changes
- Minor — internal ticket ID in public source. Two new test comments (
DPoPNonceCacheTest.kt:94,DPoPRequestDecoratorTest.kt:304) cite an internal work-item ID that external readers can't resolve. Describe the behavior instead. - Doc claim contradicted by existing code.
DPoPNonceCache.kt:36's corrected KDoc states resource-server responses "never carryDPoP-Nonce, on success or on rejection."AuthenticationUtilities.fetchIsSalesforceIntegrationUser(unchanged by this PR) calls the userinfo endpoint — a resource server — and explicitly harvests a nonce from every response and retries on an RFC 9449 nonce challenge from it. That's the opposite of "never." Suggest softening the wording to match what the SDK's own code already handles.
Open Questions
DPoPNonceCache.kt:77—clearAll()has no production call site after this change (only test teardown uses it). Intentional for a future global sign-out path, or test-only at this point?- PR description's regression-check section says 21 non-community tests were run in
DPoPLoginTests; the file has 18@Testmethods. The three named failures are real and match, so this doesn't change the regression conclusion — just flagging the count.
This review was generated by an AI agent on behalf of @JohnsonEricAtSalesforce.
| } | ||
|
|
||
| /* | ||
| * W-24342940: Salesforce only issues DPoP-Nonce from the token endpoint; resource |
There was a problem hiding this comment.
Minor: this embeds an internal work-item ID in public SDK source, unresolvable to external readers. Consider describing the behavior instead of citing the ticket.
This review was generated by an AI agent on behalf of @JohnsonEricAtSalesforce.
There was a problem hiding this comment.
Fixed in 8b3740a: the comment now describes the behavior instead of citing the ticket.
| ) | ||
|
|
||
| /* | ||
| * W-24342940: resource servers (identity, REST) never issue their own DPoP-Nonce |
There was a problem hiding this comment.
Same as the comment on DPoPNonceCacheTest.kt — worth stripping the internal ticket reference here too.
This review was generated by an AI agent on behalf of @JohnsonEricAtSalesforce.
| * ensuring per-user isolation consistent with [DPoPKeyManager]. | ||
| * 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 |
There was a problem hiding this comment.
This says resource-server responses "never" carry DPoP-Nonce, on success or on rejection. AuthenticationUtilities.fetchIsSalesforceIntegrationUser calls the userinfo endpoint (a resource server) and already harvests a nonce from its responses and retries on a nonce challenge from it — so resource servers evidently can supply one. Worth softening "never" to match what the SDK already handles.
This review was generated by an AI agent on behalf of @JohnsonEricAtSalesforce.
There was a problem hiding this comment.
Fixed in 8b3740a: the KDoc now says resource servers aren't expected to issue nonces, and notes that the SDK still harvests a DPoP-Nonce from any response that carries one (e.g. the userinfo nonce-challenge retry in AuthenticationUtilities).
| latestByCredential.remove(credentialsIdentifier) | ||
| } | ||
|
|
||
| fun clearAll() { |
There was a problem hiding this comment.
clearAll() has no production call site after this change — only test teardown uses it. Intentional for a future global sign-out path, or test-only at this point? Not blocking.
This review was generated by an AI agent on behalf of @JohnsonEricAtSalesforce.
There was a problem hiding this comment.
Pre-existing: clearAll() existed before this PR and was already only called from tests. This PR only makes it also clear the new latestByCredential map, so the two maps stay consistent.
|
Thanks for the review. I addressed the inline comments in 8b3740a, and corrected the regression-check count in the description: |
…uish exact from fallback nonce
Summary
DPoP login to a community (Experience Cloud) login server fails on Android with
403 Bad_OAuth_Tokenon the identity call, even though the/tokenexchange itselfsucceeds and correctly returns
token_type=DPoP. The fix is a one-file, client-sidechange to
DPoPNonceCache: it now falls back to the most recently issued nonce for acredential when no nonce has been cached yet for the exact host being called. No
server-side change is required.
Symptom
Logging in with DPoP enabled against a community org (tested against
capricornjuices.my.site.com/capricornjuices.my.salesforce.com) fails duringlogin: the token exchange succeeds (
200,token_type=DPoP), the identity call failswith
403 Bad_OAuth_Token, the SDK's 403-triggers-refresh path fires, the refresh alsosucceeds, and the retried identity call fails again the same way. The SDK gives up, no
account is created. With DPoP disabled (Bearer), the same org/user logs in
successfully.
Request/response sequence (before the fix)
GET /services/oauth2/authorize?...&dpop_jkt=...—dpop_jktcorrectly attached.POST /services/oauth2/token(DPoP proof, no nonce) → 400use_dpop_nonce,DPoP-Nonceresponse header present → nonce harvested, request retried.POST /services/oauth2/tokenretried with the harvested nonce → 200,token_type=DPoP.GET {instanceHost}/id/{orgId}/{userId}(identity service, DPoP proof with nononceclaim) → 403Bad_OAuth_Token. The response carries noWWW-Authenticate/DPoP-Nonceheader and nouse_dpop_noncebody — it is a flatrejection, not an RFC 9449 nonce challenge.
POST /services/oauth2/token(grant_type=refresh_tokenor
hybrid_refresh, DPoP proof with the cached nonce) → 200, new DPoP-boundtoken, refresh token rotated.
nonceclaim — the login/instance hostnever had a nonce cached for it) → 403
Bad_OAuth_Tokenagain.This reproduced identically regardless of grant type (
hybrid_auth_code/hybrid_refreshvs. plain
authorization_code/refresh_token) — ruling out hybrid auth token flow as afactor — and regardless of which identity URL variant was tried (
idUrlWithInstance,raw
idUrlper the existing pool-server workaround, or a community-base/id/path);every URL that actually reaches the real identity service was rejected the same way.
Root cause
This is the general Salesforce DPoP nonce model, not something specific to this org:
only the token endpoint issues nonces (via the standard
400 use_dpop_nonce+DPoP-Nonceheader challenge). Resource servers (identity, REST) are not expected toissue their own nonce challenge; when a DPoP-protected call fails for lack of a valid
nonce, the client is expected to go back to the token endpoint, which issues a nonce
when one is needed, and reuse that nonce on the resource-server call.
Android's
DPoPNonceCacheis keyed strictly by(credentialsIdentifier, host)with nofallback. A nonce harvested from the
/tokenhost is therefore never visible to a proofbeing built for a different host (the identity/instance host), so that proof goes out
with no
nonceclaim at all and gets rejected outright.Direct confirmation (throwaway probe, not part of this PR): forcing the exact nonce
harvested from
/tokeninto proofs sent to the identity and REST hosts — with nothingelse changed — turned every one of those 403/401s into a 200, both before and after a
token refresh cycle. This isolates the bug precisely to nonce-cache scoping, not to the
identity URL chosen, the grant type used, or any server-side DPoP support gap.
iOS does not hit this because its
DPoPNonceCacheis keyed only by credential scope(not by host):
nonce(htu:scope:) ?? latest(forScope:)falls back to the most recentlystored nonce for that scope on any host when the exact
(htu, scope)lookup misses.Android's class doc incorrectly claimed its per-host keying "matches" iOS's — it does
the opposite; this PR corrects that doc comment too.
The fix
libs/SalesforceSDK/src/com/salesforce/androidsdk/auth/dpop/DPoPNonceCache.kt:latestByCredential: ConcurrentHashMap<String, String>, trackingthe most recently stored nonce per
credentialsIdentifier(any host).get(credentialsIdentifier, host)now returns the exact(credentialsIdentifier, host)entry if present, otherwise falls back tolatestByCredential[credentialsIdentifier].An exact host match always takes precedence over the fallback, preserving Android's
existing AS/RS per-host isolation (login-host and instance-host nonces still never
overwrite each other in the primary map) while adding the missing cross-host carry-over
for resource servers that never challenge for their own nonce.
store()writes to both maps;clear()/clearAll()remove from both, so logging outor switching accounts fully clears the fallback too.
DPoPRequestDecorator.attachProofandOAuth2's token-endpoint nonce handling bothcall
DPoPNonceCache.get/storedirectly, so this fix applies transparently to boththe token endpoint and all resource-server calls with no other code changes needed.
behavior.
This is a minimal, additive change: existing exact-host behavior is unchanged and takes
priority; the fallback only engages when a host has never had its own nonce cached.
Testing
Investigation/community tree (not part of this PR; cannot be, since it requires
test-org credentials and throwaway debug logging/UI test scaffolding that is being kept
out of this change):
/token400→200 withnonce harvested, identity call now carries that nonce and returns 200 (previously 403),
refresh succeeds, account is created,
loggedIn=true./token-harvestednonceclaim.This PR (clean
fix-dpop-nonce-cross-hostbranch offupstream/dev):DPoPNonceCacheTest.kt: cross-host fallback is used when no exactentry exists; an exact host match still takes precedence over the fallback;
clear()removes the fallback entry too; a credential with no fallback stored never leaks
another credential's fallback nonce.
DPoPRequestDecoratorTest.ktverifying the fallbacknonce is actually present in the
nonceclaim of a real signed DPoP proof produced byDPoPRequestDecorator.applyAuthHeaderswhen the request's host differs from the hostthe nonce was stored for.
emulator-5554(./gradlew :libs:SalesforceSDK:connectedAndroidTest)../gradlew :libs:SalesforceSDK:buildsucceeds.Regression check: ran the full pre-existing
DPoPLoginTestsinstrumented UI testclass (18 tests covering standard DPoP login, RTR, hybrid flow, multi-user,
login-pool-server, and revoke/refresh scenarios) against the fix. Three tests —
testECAJwtDPoP_RevokeWhenInFlight_PreservesBindingAndRecovers,testECAJwtDPoP_Hybrid,and
testECAJwtDPoP_ViaLoginPoolServer_Rtr— failed on a UI assertion insideAuthFlowTesterPageObject.validateApiRequest("Request Successful" vs. "RequestFailed"), after the DPoP login itself had already completed successfully in every case
(token exchange, identity call, and in two cases a hybrid/pool-server refresh all
returned 200 before the failure). To confirm this wasn't a regression, the same three
tests were re-run against the unmodified pre-fix
DPoPNonceCacheand failed in theexact same way, at the exact same assertion — this is a pre-existing issue in the test
harness, unrelated to this change, and is not addressed by this PR.
Related tickets
Bad_OAuth_Tokeninstead of a nonce challenge") and was correctly closed as Not a Bug:by design, resource servers don't issue their own nonce challenge, and the client is
expected to reuse the nonce obtained from the token endpoint.
chose the raw
idUrloveridUrlWithInstanceinfetchUserIdentityWithRetry(MyDomain rejects pool-issued DPoP tokens on
idUrlWithInstance). That fix most likelyworked only incidentally for the pool-server case, by making the identity host match
the token host so the existing per-host nonce cache entry applied. It does not help
the community case, since the identity host there is never the token host. This PR is
orthogonal to and compatible with that existing workaround — no URL-selection change
is made here.
Note for maintainers
Separately from this fix: this org's resource servers reject a proof with a missing or
invalid nonce with a flat
403 Bad_OAuth_Token, with no information identifying thenonce as the problem. A more specific error for a missing/invalid DPoP nonce would make
this class of issue much easier to diagnose client-side, but is not required for this
fix to be correct or complete.