Skip to content

DPoP proof JWT on API calls (with nonce retry per RFC 9449 §8/§9)#4060

Open
sfdctaka wants to merge 13 commits into
forcedotcom:dpopfrom
sfdctaka:feature/dpopApiCalls
Open

DPoP proof JWT on API calls (with nonce retry per RFC 9449 §8/§9)#4060
sfdctaka wants to merge 13 commits into
forcedotcom:dpopfrom
sfdctaka:feature/dpopApiCalls

Conversation

@sfdctaka

@sfdctaka sfdctaka commented Jun 11, 2026

Copy link
Copy Markdown
Contributor

This PR extends DPoP coverage from the token endpoint to the SDK's authenticated REST and identity calls, plus adds the nonce-retry machinery the resource server requires per RFC 9449 §8/§9.

What's included

DPoP on outbound API calls

  • SFRestRequest / SFRestAPI: when tokenType == "DPoP", requests are stamped with Authorization: DPoP <access_token> and a DPoP proof header bound to the request's method (htm) and URL (htu), with the access-token hash (ath) per RFC 9449 §4.3.
  • SFIdentityCoordinator: same stamping for the identity endpoint.
  • Bearer requests are untouched — Authorization: Bearer … is byte-identical to pre-DPoP output for non-DPoP token responses.

Nonce challenge + retry (RFC 9449 §8 / §9)

  • DPoPNonceCache: per-account, per-htu cache of the most recent server-issued nonce. Harvested from any 200, 400, or 401 response carrying a DPoP-Nonce header.
  • Outbound proofs include nonce when the cache has one for the target htu.
  • On a 401 with DPoP-Nonce (or a 400 body containing error=use_dpop_nonce), the originating request is retried once with the freshly harvested nonce. Retry is one-shot to avoid loops.

Key material

  • DPoPKeyStore: per-account ES256 / P-256 keypair, generated on first DPoP use and persisted in the keychain. The private key never leaves secure storage. cnf.jkt thumbprint computed via JWK SHA-256.

Token-type wiring

  • SFOAuthCredentials.tokenType (added in earlier PR) drives scheme selection. "DPoP" ⇒ DPoP path; anything else (including nil) ⇒ Bearer path.

Backward compatibility

Purely additive. Non-DPoP orgs see no behavior change. The new headers are only emitted when the token endpoint returns token_type=DPoP.

Test coverage

  • SFSDKDPoPTests (988 lines): proof builder, nonce cache (per-account isolation, per-htu keying, eviction), request decorator (Bearer vs DPoP branch, nonce attachment, ath computation), end-to-end request stamping, retry on nonce challenge.
  • Existing SFOAuthCredentialsTests and token-endpoint response tests updated.

Notes for reviewers

  • Concurrency for REST callers: today's caller patterns are largely serial after refresh; concurrent in-flight callers may each hit a `use_dpop_nonce` challenge and only one will rotate cleanly. A TODO marks the spot to revisit if/when concurrent DPoP REST calls become common (per-`htu` lock vs. pre-fetched nonce vs. accept-extra-round-trip).
  • This PR targets the `dpop` integration branch; final merge to `dev` will follow once the backend resource-server rollout is complete.

@wmathurin wmathurin 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.

Looks good to me.

sfdctaka added 3 commits June 16, 2026 14:09
- Add SFSDKDPoPRequestDecorator.sendWithNonceRetry helper:
  harvest DPoP-Nonce on every response, retry once on
  use_dpop_nonce challenge, surface NSError on second
  consecutive challenge.
- Wire helper into REST API, identity coordinator, and
  userinfo. Photo path harvests only (cosmetic, no retry),
  gated on DPoP credentials so Bearer logins are unaffected.
- Add kSFOAuthErrorDPoPNonceExhausted to the OAuth error
  enum for the terminal nonce-exhaustion case.
@sfdctaka sfdctaka changed the title DPoP proof JWT on API calls (without nonce) DPoP proof JWT on API calls (with nonce retry per RFC 9449 §8/§9) Jun 19, 2026
@sfdctaka sfdctaka marked this pull request as ready for review June 19, 2026 23:15
@sfdctaka sfdctaka requested review from bbirman and wmathurin June 19, 2026 23:15
Comment thread libs/SalesforceSDKCore/SalesforceSDKCore/Classes/Identity/SFIdentityCoordinator.m Outdated
Comment thread libs/SalesforceSDKCore/SalesforceSDKCore/Classes/Identity/SFIdentityCoordinator.m Outdated
Comment thread libs/SalesforceSDKCore/SalesforceSDKCore/Classes/Identity/SFIdentityCoordinator.m Outdated

@wmathurin wmathurin 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.

LGTM - but if nonce are sent by the token end point, we might be able to simplify the code a bit? Harvest the nonce whenever a refresh call happens. And also treat a nonce failure like 401s (to force a refresh and therefore to get a new nonce).

@sfdctaka

Copy link
Copy Markdown
Contributor Author

LGTM - but if nonce are sent by the token end point, we might be able to simplify the code a bit? Harvest the nonce whenever a refresh call happens. And also treat a nonce failure like 401s (to force a refresh and therefore to get a new nonce).

Let me look into this now.

Reviewer feedback noted that since the token endpoint is the only
source of DPoP-Nonce headers, the resource-server retry helper was
redundant — a nonce rejection has the same client-side effect as a
401, and the existing 401-refresh-replay path already harvests a
fresh nonce during the refresh and re-stamps the proof on replay
via applyAuthHeaders + DPoPNonceCache.latest(forScope:).

- Remove DPoPRequestDecorator.sendWithNonceRetry(...)
- Remove kSFOAuthErrorDPoPNonceExhausted
- Revert SFRestAPI, SFIdentityCoordinator, SFUserAccountManager
  callsites to plain network sendRequest:dataResponseBlock:
- Keep applyAuthHeaders pre-stamp at each resource-server callsite
- Update DPoPNonceCache doc comment to reflect token-endpoint-only
  harvest design
- Drop SFSDKDPoPTests retry-helper test block and DPoPNonceExhausted
  symbol test; keep Bearer-photo cache-leak regression test
@sfdctaka

Copy link
Copy Markdown
Contributor Author

@wmathurin Dropped the resource-server reactive-retry layer and folded nonce failures into the existing 401→refresh→replay path, per your suggestion.

If applyAuthHeaders returns an error, sending the request without the
auth header is pointless — the server will reject it. Surface the
failure to the caller (delegate / completion / error block) and return
instead of issuing an unauthenticated request.
@wmathurin

Copy link
Copy Markdown
Contributor

@wmathurin Dropped the resource-server reactive-retry layer and folded nonce failures into the existing 401→refresh→replay path, per your suggestion.

I love the simplification.

I see that the nonce cache is still in memory only so an application restart will force a refresh (because of missing the nonce for creating the JWT proof). Should we persist the nonce to disk (alongside the access token and other user credentials) to avoid that situation? Or maybe we could store the JWT proof that we generate from the nonce (as long as there is a 1-1 mapping from nonce to JWT proof)?

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.

3 participants