feat(dpop): merge DPoP branch to dev (W-23384751)#2969
Merged
Conversation
…mentation reference
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.
…registration WorkManager data (forcedotcom#2951)
… 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.
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.
…pUrl.get().host()
…e matches iOS behaviour
…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)
brandonpage
approved these changes
Jul 17, 2026
Generated by 🚫 Danger |
Generated by 🚫 Danger |
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Merges the
dpopbranch intodev, bringing full DPoP (Demonstrating Proof of Possession) support per RFC 9449 to the Android SDK.PRs included
dpop_jktin/authorizefor my-domain serversTest plan
DPoPLoginTestspass locally on device