Skip to content

@W-24342940: Fix DPoP nonce reuse across hosts for community logins - #3054

Open
wmathurin wants to merge 5 commits into
forcedotcom:devfrom
wmathurin:fix-dpop-nonce-cross-host
Open

wmathurin wants to merge 5 commits into
forcedotcom:devfrom
wmathurin:fix-dpop-nonce-cross-host

Conversation

@wmathurin

@wmathurin wmathurin commented Oct 1, 2026 •

Copy link
Copy Markdown
Contributor

Summary

DPoP login to a community (Experience Cloud) login server fails on Android with
403 Bad_OAuth_Token on the identity call, even though the /token exchange itself
succeeds and correctly returns token_type=DPoP. The fix is a one-file, client-side
change to DPoPNonceCache: it now falls back to the most recently issued nonce for a
credential 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 during
login: the token exchange succeeds (200, token_type=DPoP), the identity call fails
with 403 Bad_OAuth_Token, the SDK's 403-triggers-refresh path fires, the refresh also
succeeds, 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)

  1. GET /services/oauth2/authorize?...&dpop_jkt=... — dpop_jkt correctly attached.
  2. POST /services/oauth2/token (DPoP proof, no nonce) → 400 use_dpop_nonce,
    DPoP-Nonce response header present → nonce harvested, request retried.
  3. POST /services/oauth2/token retried with the harvested nonce → 200,
    token_type=DPoP.
  4. GET {instanceHost}/id/{orgId}/{userId} (identity service, DPoP proof with no
    nonce claim
    ) → 403 Bad_OAuth_Token. The response carries no
    WWW-Authenticate/DPoP-Nonce header and no use_dpop_nonce body — it is a flat
    rejection, not an RFC 9449 nonce challenge.
  5. SDK treats the 403 as refreshable: POST /services/oauth2/token (grant_type=refresh_token
    or hybrid_refresh, DPoP proof with the cached nonce) → 200, new DPoP-bound
    token, refresh token rotated.
  6. Retried identity call (new proof, still no nonce claim — the login/instance host
    never had a nonce cached for it) → 403 Bad_OAuth_Token again.
  7. SDK gives up, clears cookies, reloads login. No account created.

This reproduced identically regardless of grant type (hybrid_auth_code/hybrid_refresh
vs. plain authorization_code/refresh_token) — ruling out hybrid auth token flow as a
factor — and regardless of which identity URL variant was tried (idUrlWithInstance,
raw idUrl per 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-Nonce header challenge). Resource servers (identity, REST) are not expected to
issue 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 DPoPNonceCache is keyed strictly by (credentialsIdentifier, host) with no
fallback. A nonce harvested from the /token host is therefore never visible to a proof
being built for a different host (the identity/instance host), so that proof goes out
with no nonce claim at all and gets rejected outright.

Direct confirmation (throwaway probe, not part of this PR): forcing the exact nonce
harvested from /token into proofs sent to the identity and REST hosts — with nothing
else 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 DPoPNonceCache is keyed only by credential scope
(not by host): nonce(htu:scope:) ?? latest(forScope:) falls back to the most recently
stored 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:

  • Added a second map, latestByCredential: ConcurrentHashMap<String, String>, tracking
    the most recently stored nonce per credentialsIdentifier (any host).
  • get(credentialsIdentifier, host) now returns the exact (credentialsIdentifier, host) entry if present, otherwise falls back to latestByCredential[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 out
    or switching accounts fully clears the fallback too.
  • DPoPRequestDecorator.attachProof and OAuth2's token-endpoint nonce handling both
    call DPoPNonceCache.get/store directly, so this fix applies transparently to both
    the token endpoint and all resource-server calls with no other code changes needed.
  • Corrected the class doc to describe the fallback and reference iOS's equivalent
    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):

  • With the fix applied, DPoP community login succeeds end-to-end: /token 400→200 with
    nonce harvested, identity call now carries that nonce and returns 200 (previously 403),
    refresh succeeds, account is created, loggedIn=true.
  • DPoP-disabled (Bearer) control case still passes.
  • Confirmed via logcat that the identity/REST proofs now carry the /token-harvested
    nonce claim.

This PR (clean fix-dpop-nonce-cross-host branch off upstream/dev):

  • Added unit tests to DPoPNonceCacheTest.kt: cross-host fallback is used when no exact
    entry 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.
  • Added an integration test to DPoPRequestDecoratorTest.kt verifying the fallback
    nonce is actually present in the nonce claim of a real signed DPoP proof produced by
    DPoPRequestDecorator.applyAuthHeaders when the request's host differs from the host
    the nonce was stored for.
  • All 31 tests across both files pass on emulator-5554 (./gradlew :libs:SalesforceSDK:connectedAndroidTest).
  • ./gradlew :libs:SalesforceSDK:build succeeds.

Regression check: ran the full pre-existing DPoPLoginTests instrumented UI test
class (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 inside
AuthFlowTesterPageObject.validateApiRequest ("Request Successful" vs. "Request
Failed"), 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 DPoPNonceCache and failed in the
exact 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

  • W-23992239 was a server bug report ("DPoP identity requests return
    Bad_OAuth_Token instead 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.
  • PR W-24185374: Harden concurrent REST and pool login #3038 is the Android change that, for a DPoP token issued via a pool server,
    chose the raw idUrl over idUrlWithInstance in fetchUserIdentityWithRetry (My
    Domain rejects pool-issued DPoP tokens on idUrlWithInstance). That fix most likely
    worked 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 the
nonce 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.

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 JohnsonEricAtSalesforce left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

  1. 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.
  2. Doc claim contradicted by existing code. DPoPNonceCache.kt:36's corrected KDoc states resource-server responses "never carry DPoP-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

  1. 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?
  2. PR description's regression-check section says 21 non-community tests were run in DPoPLoginTests; the file has 18 @Test methods. 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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 8b3740a: same change here.

* 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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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() {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@wmathurin

Copy link
Copy Markdown
Contributor Author

Thanks for the review. I addressed the inline comments in 8b3740a, and corrected the regression-check count in the description: DPoPLoginTests has 18 tests, not 21.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants