Skip to content

feat(dpop): merge DPoP branch to dev (W-23384751)#2969

Merged
wmathurin merged 81 commits into
forcedotcom:devfrom
wmathurin:dpop
Jul 17, 2026
Merged

feat(dpop): merge DPoP branch to dev (W-23384751)#2969
wmathurin merged 81 commits into
forcedotcom:devfrom
wmathurin:dpop

Conversation

@wmathurin

Copy link
Copy Markdown
Contributor

Summary

Merges the dpop branch into dev, bringing full DPoP (Demonstrating Proof of Possession) support per RFC 9449 to the Android SDK.

PRs included

Test plan

  • All non-skipped tests in DPoPLoginTests pass locally on device
  • Manual end-to-end DPoP login verified on device

wmathurin and others added 30 commits June 25, 2026 17:33
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.
Added 'dpop' branch to pull_request_target branches.
Adds client-side DPoP (RFC 9449) support to the Android SDK's OAuth
token exchange flows. When SalesforceSDKManager.isUseDPoP() is true,
the SDK:

- Generates an EC P-256 keypair per user account in Android Keystore
  (hardware-backed when available, aliased as "dpop_<uuid>") and reuses
  it across refreshes
- Builds a DPoP proof JWT (compact JWS: header.payload.signature) and
  attaches it as the DPoP HTTP header on every token endpoint request
  (both authorization code exchange and refresh token flow)
- Detects token_type:"DPoP" in the server response and persists it on
  the credentials (UserAccount, OAuthRefreshInterceptor)
- Uses Authorization: DPoP <token> instead of Bearer on subsequent API
  calls via OAuthRefreshInterceptor

New classes (com.salesforce.androidsdk.auth.dpop):
- DPoPURLHelper: RFC 9449 §4.2 URL canonicalization (strips query+fragment)
- DPoPKeyManager: Android Keystore EC P-256 keypair lifecycle
- DPoPProofBuilder: compact JWS construction (JWK export, jti, DER→R||S)

New tests:
- DPoPURLHelperTest, DPoPKeyManagerTest, DPoPProofBuilderTest
- OAuth2MockTests: 8 new DPoP tests (header present/absent, token type,
  auth scheme selection)

Out of scope: nonce challenge/retry (Phase 4), API call proofs with ath
claim (Phase 3), automated UI tests (Phase 5).
Delete the DPoP keypair from Android Keystore on logout to prevent
keystore bloat across login/logout cycles. Mirrors the iOS cleanup
in SFUserAccountManager.m which calls SFSDKDPoPKeyStore.deleteForCredentials.
The field on UserAccount was named dpopScope but is semantically a
keystore alias identifier (UUID), not an OAuth scope. Renamed to
credentialsIdentifier to match the rest of the codebase (LoginViewModel,
AuthenticationUtilities, OAuth2, DPoPKeyManager.aliasForCredentialsIdentifier)
and to align with the iOS implementation's terminology.

Also renames the persisted JSON/Bundle key from "dpopScope" to
"credentialsIdentifier". Safe to change pre-release on the feature branch.

W-22695293
feat(W-22695293): DPoP proof JWT at token exchange (Phase 2, Android)
- 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
- 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
Add RFC 9449 §8 nonce support across all DPoP request paths.

- DPoPNonceCache: new thread-safe ConcurrentHashMap, keyed by
  credentialsIdentifier (per-user, consistent with DPoPKeyManager)
- OAuthRefreshInterceptor: proactively cache DPoP-Nonce from every
  response; retry once on use_dpop_nonce challenge (400/401)
- OAuth2.makeTokenEndpointRequest: same nonce caching + retry strategy
- OAuth2.callIdentityService: same nonce caching + retry (fail-closed)
- SalesforceSDKManager.removeAccount: clear nonce cache on logout
  alongside DPoP keypair deletion

Tests:
- DPoPNonceCacheTest: store/get, clear, per-user isolation, concurrency
- OAuthRefreshInterceptorNonceTest: no-nonce, cached-nonce in proof,
  nonce harvested from success, use_dpop_nonce retry, no second retry,
  Bearer type unaffected
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 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.
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.
AS (login host) and RS (instance host) each issue independent DPoP-Nonce
values. Previously the cache was keyed only by credentialsIdentifier, so
the refresh response nonce (AS) overwrote the API-server nonce (RS),
causing the replayed request to be rejected with 401. The cache is now
keyed by (credentialsIdentifier, host), matching iOS per-host isolation.
Also adds HttpUrl import to OAuth2.java and removes temporary debug logs.
createTestAccountJSON() and createTestAccountBundle() were missing
credentialsIdentifier and tokenType, causing testConvertAccountToBundle
and testConvertAccountToJSON to fail after createTestAccount() started
populating those fields.
Same temporary exception as pr.yaml. Remove once DPoP merges back to dev.
- 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)
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.
feat(W-22697744): DPoP proof JWT on API calls (Phase 3)
Temporary entry so PRs in the multi-PR DPoP rollout (targeting the dpop
integration branch) trigger the Pull Request CI workflow. GitHub evaluates
pull_request_target against the default branch (dev) copy of the workflow,
so the dpop branch's copy alone is not sufficient.

Remove this entry once DPoP merges back to dev.
Same temporary exception as pr.yaml. Remove once DPoP merges back to dev.
- 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)
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.
… detection

Add FEATURE_RTR ("RT") constant to Features.java and register it as a
per-user feature flag in ClientManager when a server-side refresh token
rotation is detected (new refresh token in token endpoint response).
Adds a unit test verifying per-user isolation of the RT flag.
wmathurin added 23 commits July 10, 2026 13:41
W-22698013: DPoP automated UI tests (Phase 5)
…in dev info screen

Add OAuth Token Type row (always) and DPoP Nonce + DPoP Key Thumbprint rows
(DPoP sessions only) to the Current User section of DevInfoActivity.
Add jwkThumbprint() to DPoPProofBuilder for RFC 7638 JWK SHA-256 thumbprint.
Add docs/dev-info-screen.md reference doc for the developer info screen.
…s only issued at token endpoint

Salesforce confirmed DPoP-Nonce is only returned by the token endpoint.
The inline harvest+retry in callIdentityService() could never fire.
Identity 401s already recover via OAuthRefreshInterceptor, which
re-enters the token endpoint where nonce handling lives. Aligns Android
with iOS behaviour.
W-23195012: Android DP per-user feature flag for DPoP sessions
…tials

Add DPoP Key Thumbprint row to UserCredentialsView (DPoP section) and
expose it via DpopInfo in the page object. validateOAuthValues() now
asserts the thumbprint is a valid 43-char base64url string, matching
the RFC 7638 JWK thumbprint that DevSupportInfo already computes.
…6836)

When useDPoP=true and the login server is a custom/my-domain server,
generate credentialsIdentifier eagerly in generateAuthorizationUrl(),
load the EC key pair, compute the RFC 7638 JWK thumbprint, and add
dpop_jkt=<thumbprint> to the authorization URL parameters.

The identifier is stored as pendingCredentialsIdentifier so
doCodeExchange() reuses it, preserving the key pair binding from
authorize -> token exchange.

Pool servers (login.salesforce.com, test.salesforce.com,
welcome.salesforce.com) are excluded — they don't support DPoP code
binding, matching the same guard already used in
fetchAuthenticationConfiguration() for browser login and app attestation.
W-23192897: surface DPoP runtime state in developer info screen
… stubs and server URLs (W-23406836)

- Rename helper to addDpopJktIfNeeded for clarity
- Fix DPoP unit tests: use my-domain URL (myorg.my.salesforce.com) instead of
  test.salesforce.com so dpop_jkt is not excluded by the pool-server guard
- Add missing useDPoP stub to two strict (relaxed=false) mock tests
…erver re-entry (W-23406836)

- On early return (pool server or DPoP disabled), remove any previous dpop_jkt from
  params, delete the orphaned AndroidKeyStore entry, and clear pendingCredentialsIdentifier
  so the two stay in sync
- On my-domain happy path, delete the previous orphaned key before generating a new one
  so repeated server-picker navigation doesn't accumulate unbounded keystore entries
Consolidates the repeated three-way pool-server check
(PRODUCTION / SANDBOX / WELCOME) into a single static method on
LoginServerManager, and updates all three call sites:
- LoginViewModel.addDpopJktIfNeeded
- SalesforceSDKManager.fetchAndUpdateAuthConfigIfNeeded
- SalesforceDroidGapActivity.fetchAuthConfig
…W-23406836)

Decouples the login surface selection (Custom Tab vs in-app WebView) from
useWebServerFlow. Moves the new parameter after knownUserConfig so existing
positional call sites are unaffected.
… AuthFlowTester (W-23406836)

- AndroidManifest: add missing intent filters for ecajwtrtr, ecaopaquertr, ecajwtdpop, ecajwtdpoprtr
- AuthFlowTest: forward forceAdvancedAuthentication as expectAdvancedAuth in loginAndValidate/validateUser
- AuthFlowTest: add expectAdvancedAuth/isDpop/isMultiUser params to assertRevokeAndRefreshWorks, migrateAndValidate, restartAndValidateUser, switchToUserAndValidateUser
- AuthFlowTest: fix adminLoginAndValidate to forward useDPoP to validateUser
- AuthFlowTest: remove stale nonce-change assertion (server issues nonces with 24h TTL, not per-refresh)
- DPoPLoginTests: pass isDpop/isMultiUser at all call sites; ignore RTR+hybrid test (W-22512846); use useHybridAuthToken=false for DPoP→DPoPRtr migration
feat(dpop): send dpop_jkt in /authorize for my-domain servers (W-23406836)
@github-actions

Copy link
Copy Markdown
1 Warning
⚠️ Big PR, try to keep changes smaller if you can.

Generated by 🚫 Danger


object DPoPURLHelper {
fun canonicalize(url: String): String =
Uri.parse(url).buildUpon().clearQuery().fragment(null).build().toString()

@github-actions github-actions Bot Jul 17, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Use the KTX extension function String.toUri instead?

@github-actions

github-actions Bot commented Jul 17, 2026

Copy link
Copy Markdown
7 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#L56 - 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
⚠️ libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/LoginActivity.kt#L715 - Switch statement on an int with known associated constant missing case BiometricManager.BIOMETRIC_ERROR_IDENTITY_CHECK_NOT_ACTIVE
⚠️ libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/LoginOptionsActivity.kt#L424 - This method should only be accessed from tests or within private scope
⚠️ libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/LoginViewModel.kt#L323 - This method should only be accessed from tests or within private scope
⚠️ libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/LoginViewModel.kt#L327 - This method should only be accessed from tests or within private scope
⚠️ libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/LoginViewModel.kt#L331 - This method should only be accessed from tests or within private scope

Generated by 🚫 Danger

@github-actions

github-actions Bot commented Jul 17, 2026

Copy link
Copy Markdown
1 Warning
⚠️ libs/SalesforceHybrid/src/com/salesforce/androidsdk/phonegap/ui/SalesforceDroidGapActivity.kt#L168 - Field requires API level 33 (current min is 31): android.content.Context#RECEIVER_NOT_EXPORTED

Generated by 🚫 Danger

…751)

Three changes eliminate the intermittent 'can't find stub Companion' failure:

1. Move realUserAccountManager init from class field to @before so it
   never fires during JUnit class construction, which can race with a
   prior test's unmockkAll() leaving the companion partially unregistered.

2. In installAppAttestationClient and the inline fetchChallenge test,
   capture the real SDK manager and build the spy *before* calling
   mockkObject(SalesforceSDKManager) so that getInstance() is never
   called after the companion is intercepted but before the every{}
   stub is registered.
@wmathurin
wmathurin merged commit 3e3d479 into forcedotcom:dev Jul 17, 2026
5 of 6 checks passed
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.

4 participants