Skip to content

feat(W-22697744): DPoP proof JWT on API calls (Phase 3)#2949

Open
wmathurin wants to merge 11 commits into
forcedotcom:dpopfrom
wmathurin:dpop-phase3
Open

feat(W-22697744): DPoP proof JWT on API calls (Phase 3)#2949
wmathurin wants to merge 11 commits into
forcedotcom:dpopfrom
wmathurin:dpop-phase3

Conversation

@wmathurin

Copy link
Copy Markdown
Contributor

Summary

  • DPoPProofBuilder: buildPayload now includes the ath claim (base64url(SHA-256(accessToken))) when accessToken is non-null/non-empty, and the nonce claim when non-null/non-empty (was silently ignored before)
  • RestClient: new attachDPoPProofHeader helper attaches a DPoP proof on every outgoing API request when tokenType == "DPoP"; OAuthRefreshInterceptor.buildAuthenticatedRequest also calls attachDPoPProofIfNeeded so the 401-retry path gets a fresh proof too; credentialsIdentifier threaded through all constructor chains
  • ClientManager.peekRestClient: now passes userAccount.getTokenType() and userAccount.getCredentialsIdentifier() to RestClient
  • OAuth2.callIdentityService: new overload attaches DPoP proof (with ath) for the identity endpoint when token is DPoP-bound; TokenEndpointResponse carries credentialsIdentifier for downstream callers; makeTokenEndpointRequest sets it on the response
  • AuthenticationUtilities: fetchUserIdentity passes tokenType and credentialsIdentifier from the token response to the identity service call
  • DPoPProofBuilderTest: 4 new ath claim tests (present, null, empty, deterministic)

Test plan

  • DPoPProofBuilderTest — all 11 tests pass (7 existing + 4 new ath tests)
  • DPoPKeyManagerTest — all 5 tests pass (unchanged)
  • DPoPURLHelperTest — all 4 tests pass (unchanged)
  • Manual: log in with a DPoP-enabled org and confirm API calls carry Authorization: DPoP <token> and DPoP: <proof> headers with ath claim
  • Manual: confirm Bearer orgs are unaffected (no DPoP header, Authorization: Bearer <token>)

Analysis doc belongs with implementation spec/plan in the workspace
repo, not in the Android repo — it references upcoming DPoP work rather
than describing the existing codebase permanently.
- DPoPProofBuilder: add ath claim (SHA-256 of access token) when accessToken is non-null/non-empty; add nonce claim when nonce is non-null/non-empty
- RestClient: add attachDPoPProofHeader helper that builds and attaches a DPoP proof on initial requests; OAuthRefreshInterceptor attaches proof on 401-retry path too; credentialsIdentifier threaded through constructor chain
- ClientManager.peekRestClient: passes tokenType and credentialsIdentifier to RestClient
- OAuth2.callIdentityService: new overload that attaches DPoP proof when token is DPoP-bound; TokenEndpointResponse carries credentialsIdentifier for downstream callers
- AuthenticationUtilities: fetchUserIdentity passes tokenType and credentialsIdentifier to identity service call
- DPoPProofBuilderTest: four new ath claim tests

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

Phase 3 DPoP review. The core crypto is solid — ath is computed as base64url(SHA-256(access_token)) byte-for-byte identically to iOS, base64url flags match, and the null/empty guards are right. I verified iOS Phase 3 parity (DPoPRequestDecorator / PR 4060) and ran the DPoP instrumented suite locally (DPoPProofBuilderTest 11/11, DPoPKeyManagerTest 5/5, DPoPURLHelperTest 4/4). This also closes the Phase 2 "unused ath/nonce params" thread. 🎉

Requesting changes on one item — a redundant double proof-attach on the primary REST path (each request signs twice and discards the first). Plus a minor cross-platform consistency question on the identity-service fail-open behavior; not a blocker, just flagging for a conscious decision.

This response was generated by an AI agent on behalf of @JohnsonEricAtSalesforce.

Comment thread libs/SalesforceSDK/src/com/salesforce/androidsdk/rest/RestClient.java Outdated
final String proof = DPoPProofBuilder.INSTANCE.buildProof("GET", htu, keyPair, null, authToken);
builder.header(DPOP, proof);
} catch (Exception e) {
SalesforceSDKLogger.e(TAG, "Failed to attach DPoP header for identity service, proceeding without it", e);

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.

Small cross-platform consistency question on the identity-service path. If proof attachment throws here we log and proceed — but the request still carries Authorization: DPoP <token> with no DPoP proof header, which the server will reject, so the fallback can't really succeed.

iOS made the identity coordinator (SFIdentityCoordinator) fail-closed for exactly this in commit 97a62410a — it bails out and surfaces the error rather than sending an unusable request. Worth noting the resource-API path is fine as-is: both platforms are fail-open there (iOS SFRestRequest logs and proceeds too), so this is just about the identity call site matching iOS.

Not asserting a change — this touches auth/credential handling, so flagging for a conscious call: either match iOS and surface the failure on the identity path, or keep a uniform fail-open policy across all DPoP paths and drop a one-line comment noting it's intentional. What's your preference?

This response was generated by an AI agent on behalf of @JohnsonEricAtSalesforce.

- RestClient: remove redundant DPoP proof attach from buildRequest() — the interceptor's buildAuthenticatedRequest() is the sole attach site, avoiding a double EC P-256 signing per request
- OAuth2.callIdentityService: make DPoP proof attachment fail-closed on the identity path (remove try/catch, let exceptions propagate) to match iOS SFIdentityCoordinator behaviour; a missing proof on a DPoP-bound identity request would be server-rejected anyway
credentialsIdentifier and tokenType were never stored in the Android
AccountManager (via buildAuthBundle) nor read back (via buildUserAccount).
This caused getCredentialsIdentifier() to return null at token refresh
time, bypassing the DPoP proof attachment in makeTokenEndpointRequest
and causing the server to reject the refresh with
"app requires proof of possession".

- Add KEY_CREDENTIALS_IDENTIFIER and KEY_TOKEN_TYPE constants to
  AuthenticatorService
- Store both fields (encrypted) in buildAuthBundle
- Read both fields back in buildUserAccount and pass to builder

Also enable DPoP in AuthFlowTester application and add DPoP test server.
credentialsIdentifier and tokenType were previously omitted from
buildAuthBundle/buildUserAccount. Add tests that would have caught it:

- Add TEST_CREDENTIALS_IDENTIFIER and TEST_TOKEN_TYPE to createTestAccount()
  so the existing full round-trip test (testUserAccountToAccountToUserAccount)
  now exercises the DPoP fields through the complete serialization path.
- test_givenDPoPAccount_whenCreateAndBuildUserAccount: explicit assertion that
  both fields survive createAccount → buildUserAccount.
- test_givenDPoPAccount_whenUpdateAccount: same check for updateAccount path.

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

Thanks for the quick turnaround, @wmathurin. Confirmed both items are addressed: the double proof-attach is gone (buildRequest() now defers entirely to the interceptor — 👍), and callIdentityService is fail-closed with the iOS 97a62410a reference. Nice catch on the AccountManager credentialsIdentifier/tokenType persistence gap too, and the round-trip regression tests for it pass locally.

One blocker before this is green: I ran the SalesforceSDK instrumented suite at this head and UserAccountTest.testConvertAccountToBundle and testConvertAccountToJSON both fail — the shared test-account builder now populates the two new fields, but the expected Bundle/JSON in those two tests weren't updated to match. Details inline. Everything else (DPoP suites + the new UserAccountManagerTest round-trips) passes 45/47.

This response was generated by an AI agent on behalf of @JohnsonEricAtSalesforce.

@JohnsonEricAtSalesforce

Copy link
Copy Markdown
Contributor

@wmathurin — a heads-up on CI coverage for the DPoP rollout, and a small ask.

The phase PRs targeting the dpop integration branch don't appear to be running the Pull Request test workflow. On this PR (head d26cb65b) the only checks are SAST and salesforce-cla — no Firebase Test Lab / instrumented-test job. Same story on the prior phase PR 2944. So each phase is merging into dpop without the per-PR unit/instrumented coverage and codecov gate that PRs into dev get.

The config looks right: the pr.yaml on the dpop branch already lists dpop in the pull_request_target allowlist (the temporary entry you added in 1b1ef00a3). But because the repo's default branch is dev, and dev's pr.yaml allowlist is still just [dev, master], I think GitHub is evaluating the pull_request_target trigger against the default-branch copy of the workflow — which doesn't include dpop — so the event never matches and no run is created. That would explain why there's not even a skipped/awaiting-approval run to point at.

If that's the cause, the fix is to add dpop to the allowlist on the dev copy of .github/workflows/pr.yaml (same temporary-entry treatment, removed when DPoP merges back to dev), rather than only on the dpop branch. Happy to open that one-line PR against dev if you'd like.

Worth doing now rather than at merge-back because this isn't the last phase — there are still three Android PRs queued to target dpop after this one: W-22697878 (nonce support), W-22698013 (automated UI tests), and W-23192897 (dev-info-screen DPoP state), all still open. So the coverage gap compounds across every remaining phase, and Phase 4's nonce challenge/response is exactly the kind of protocol-state logic where per-PR CI earns its keep.

As a concrete example of the debt: locally I found Phase 3 shipped with two red serialization tests (UserAccountTest.testConvertAccountToBundle / testConvertAccountToJSON) that CI would have caught immediately — per-PR CI on dpop would surface this class of thing at the point it's introduced instead of all at once when dpop lands on dev.

This response was generated by an AI agent on behalf of @JohnsonEricAtSalesforce.

createTestAccountJSON() and createTestAccountBundle() were missing
credentialsIdentifier and tokenType, causing testConvertAccountToBundle
and testConvertAccountToJSON to fail after createTestAccount() started
populating those fields.
@wmathurin

Copy link
Copy Markdown
Contributor Author

Thanks for flagging this — you're right about the root cause. Opened #2950 which adds dpop to the branches allowlist on the dev copy of pr.yaml. Once that merges, all remaining phase PRs targeting dpop (W-22697878, W-22698013, W-23192897) will get full CI.

@JohnsonEricAtSalesforce

Copy link
Copy Markdown
Contributor

@wmathurin — once the CI-enable change (2950) merges to dev, this PR won't pick up the Pull Request workflow on its own: pull_request_target is evaluated against the dev copy of pr.yaml at event time, and there's no prior run here to re-trigger. A quick Close → Re-Open on this PR after that merges should fire a fresh reopened event and kick off the full test suite (Firebase Test Lab + codecov + Danger) against the current head. A new commit/push would do the same if you have one handy — either works.

Would appreciate the close/re-open once the CI PR is in, so we get a green (or informative) CI run here before this lands. Thanks!

This response was generated by an AI agent on behalf of @JohnsonEricAtSalesforce.

@wmathurin wmathurin closed this Jul 1, 2026
@wmathurin wmathurin reopened this Jul 1, 2026
@github-actions

github-actions Bot commented Jul 1, 2026

Copy link
Copy Markdown
1 Error
🚫 Please re-submit this PR to the dev branch, we may have already fixed your issue.

Generated by 🚫 Danger

Same temporary exception as pr.yaml. Remove once DPoP merges back to dev.
@github-actions

github-actions Bot commented Jul 1, 2026

Copy link
Copy Markdown
2 Warnings
⚠️ libs/SalesforceSDK/src/com/salesforce/androidsdk/accounts/UserAccountManager.java#L112 - Do not place Android context classes in static fields (static reference to UserAccountManager which has field context pointing to Context); this is a memory leak
⚠️ libs/SalesforceSDK/src/com/salesforce/androidsdk/auth/AuthenticatorService.java#L58 - Do not place Android context classes in static fields (static reference to Authenticator which has field context pointing to Context); this is a memory leak

Generated by 🚫 Danger

- AuthenticatorServiceTest: stub isUseDPoP() on SalesforceSDKManager mock
- LoginViewModelMockTest: add credentialsIdentifier param to all
  AuthenticationUtilities.onAuthFlowComplete mock/verify blocks; update
  LoginViewModel.onAuthFlowComplete spy mocks to 6-arg signature;
  update exchangeCode mocks to 8-arg signature (now passes
  SalesforceSDKManager and credentialsIdentifier)
- ClientManagerMockTest: fix missing quotes around REFRESHED_ACCESS_TOKEN
  in refresh response JSON (caused JSON parse failure → null auth token)
@wmathurin

Copy link
Copy Markdown
Contributor Author

One blocker before this is green: I ran the SalesforceSDK instrumented suite at this head and UserAccountTest.testConvertAccountToBundle and testConvertAccountToJSON both fail — the shared test-account builder now populates the two new fields, but the expected Bundle/JSON in those two tests weren't updated to match. Details inline. Everything else (DPoP suites + the new UserAccountManagerTest round-trips) passes 45/47.

Should be addressed by 223fdc3

makeTokenEndpointRequest calls isUseDPoP() when credentialsIdentifier is
non-null. Relaxed UserAccount mocks return "" for getCredentialsIdentifier(),
so the DPoP block is entered and isUseDPoP() is called on the non-relaxed
SalesforceSDKManager mock — throwing MockKException and causing all token
refresh calls to return null.
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