Skip to content

fix(W-23836447): allow dpop_jkt on login pool servers - #2988

Merged
wmathurin merged 14 commits into
forcedotcom:devfrom
wmathurin:dpop-jkt-pool-servers
Aug 15, 2026
Merged

fix(W-23836447): allow dpop_jkt on login pool servers#2988
wmathurin merged 14 commits into
forcedotcom:devfrom
wmathurin:dpop-jkt-pool-servers

Conversation

@wmathurin

@wmathurin wmathurin commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Removes the LoginServerManager.isPoolServer(server) guard from addDpopJktIfNeeded() in LoginViewModel.kt — pool servers now support DPoP authorization code binding
  • welcome.salesforce.com/discovery is unaffected (discovery resolves a my-domain server before /authorize is called)
  • Also fixes a key-stability bug: addDpopJktIfNeeded was regenerating a new key pair on every call; during pool-server login it is called twice (once per /authorize), so the auth code's dpop_jkt used Key A but the token-exchange proof used Key C → invalid_dpop_proof. Fix: reuse pendingCredentialsIdentifier if already set within the same login flow.

Known limitation — server-side bug W-23864247

During testing against login.test1.pc-rnd.salesforce.com, the token exchange returns HTTP 400 invalid_dpop_proof even though the client's DPoP proof is cryptographically correct and its JWK thumbprint exactly matches the dpop_jkt sent in /authorize. This is a server-side issue filed as W-23864247 (Auth Protocols team). testECAJwtDPoP_ViaLoginPoolServer is @Ignored until the server fix is confirmed.

Changes

Production code

  • LoginViewModel.kt — remove isMyDomainServer check; only !sdkManager.useDPoP exits early now; fix pendingCredentialsIdentifier reuse; updated KDoc

Unit tests

  • LoginViewModelTest.kt — add generateAuthorizationUrl_WhenUseDPoP_AndPoolServer_AddsDpopJktToUrl asserting dpop_jkt present for login.salesforce.com when DPoP enabled

UI tests (AuthFlowTester)

  • ui_test_config.json.sample — add top-level loginPoolHost field
  • UITestConfig.kt — add loginPoolHost: String? to UITestConfig; add requireLoginPoolHost() accessor
  • AuthFlowTest.kt — add useLoginPoolHost: Boolean = false to loginAndValidate(); when true, calls changeServerByUrl(testConfig.requireLoginPoolHost()) while credentials come from knownLoginHostConfig
  • DPoPLoginTests.kt — add testECAJwtDPoP_ViaLoginPoolServer (@Ignore pending W-23864247)
  • RefreshTokenMigrationTests.kt — update override signature to include useLoginPoolHost

Test plan

  • ./gradlew :libs:SalesforceSDK:compileDebugKotlin — BUILD SUCCESSFUL
  • ./gradlew :libs:SalesforceSDK:compileDebugAndroidTestKotlin — BUILD SUCCESSFUL
  • ./gradlew :native:NativeSampleApps:AuthFlowTester:compileDebugAndroidTestKotlin — BUILD SUCCESSFUL
  • LoginViewModelTest — new pool-server test passes on emulator
  • DPoPLoginTests.testECAJwtDPoP_ViaLoginPoolServer — blocked on server bug W-23864247

GUS

W-23836447 — [Android] Allow dpop_jkt on login pool servers (/authorize code binding)

@github-actions

github-actions Bot commented Aug 13, 2026

Copy link
Copy Markdown
3 Warnings
⚠️ libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/LoginViewModel.kt#L328 - This method should only be accessed from tests or within private scope
⚠️ libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/LoginViewModel.kt#L332 - This method should only be accessed from tests or within private scope
⚠️ libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/LoginViewModel.kt#L336 - This method should only be accessed from tests or within private scope

Generated by 🚫 Danger

@wmathurin
wmathurin force-pushed the dpop-jkt-pool-servers branch 2 times, most recently from a7c23f8 to 56a66ad Compare August 13, 2026 00:41
Pool servers (login.salesforce.com, test.salesforce.com) now support
DPoP authorization code binding. Remove the isPoolServer guard from
addDpopJktIfNeeded() so dpop_jkt is sent for all login servers when
DPoP is enabled.

- Add pool-server unit test to LoginViewModelTest
- Add loginPoolHost / requireLoginPoolHost() to UITestConfig
- Add useLoginPoolHost param to AuthFlowTest.loginAndValidate
- Add testECAJwtDPoP_ViaLoginPoolServer to DPoPLoginTests
- Update RefreshTokenMigrationTests override to match new signature
@wmathurin
wmathurin force-pushed the dpop-jkt-pool-servers branch from 56a66ad to f9a47a3 Compare August 13, 2026 01:04
…age object

Since PR forcedotcom#2983, the login-server picker is modal and non-dismissable — it
has no close button, and the top app bar's overflow menu is behind its scrim.
Tests that called backOutToLoginActivity() followed by openLoginOptions()
were failing because openLoginOptions() tried to tap MORE_OPTIONS_BUTTON
while the picker was covering it.

Fix: backOutToLoginActivity() now simply closes the Chrome tab and waits
for either the picker or the top bar to be reachable (whichever the SDK
raises). openLoginOptions() then detects whether the picker is showing and,
if so, routes through PICKER_DEV_SUPPORT_BUTTON (the debug-only button in
the picker's own header) instead of the obscured top-bar path.
… test

Pool servers (login.*.salesforce.com) register the L1 (Production) feature
flag, not L4 (MyDomain). Fix loginAndValidate() to emit L1 when
useLoginPoolHost is true.

Also add testECAJwt_ViaLoginPoolServer to ECALoginTests so there is a
non-DPoP baseline test for pool-server login, alongside the DPoP variant
in DPoPLoginTests.
…ver to servers.xml

changeServerByUrl() now detects if the login-server picker is already visible
(left open by backOutToLoginActivity() after a tab close) and skips the
MORE_OPTIONS_BUTTON → MENU_ITEM_PICK_SERVER path, which is blocked by the
picker's modal scrim. If already showing, it selects the server directly.

Also adds the UITests Login Pool server to servers.xml so the picker can
find and select `login.test1.pc-rnd.salesforce.com` during pool-server tests.
# Conflicts:
#	native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/pageObjects/ChromeCustomTabPageObject.kt
#	native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/pageObjects/LoginPageObject.kt
…h needed

After backOutToLoginActivity() the server picker can be left showing.
ensureRegularAuthServer() was calling setSelectedLoginServer() directly,
which doesn't close the picker UI — leaving it covering the WebView for
in-app WebView tests (User Agent Flow, forceAdvancedAuthentication=false).

Fix: detect whether the picker is showing; if so, dismiss it via
changeServerByUrl() (tapping the row triggers reloadWebView and closes
the sheet) rather than calling setSelectedLoginServer() directly.
- Increased TIMEOUT_MS from 10/15s to 15/20s (local/FTL) and added
  WEBVIEW_ACTION_TIMEOUT_MS (45s local / 60s FTL) in BasePageObject to
  handle "Atom evaluation returned null" failures when the login form
  takes longer than the old 10s budget to render after a server-picker
  dismissal triggers a WebView reload.
- Added waitForPageLoad() in LoginPageObject (watches LOADING_INDICATOR
  appear/disappear) and waitForLoginScreen() (waits for MORE_OPTIONS_BUTTON);
  login() now calls waitForPageLoad() before setting the username and uses
  WEBVIEW_ACTION_TIMEOUT_MS for the retry budget.
- In AuthFlowTest.ensureRegularAuthServer(), after changeServerByUrl() for
  the picker-is-showing path, calls loginPage.waitForLoginScreen() to ensure
  the Compose hierarchy is fully settled before returning.
- Fixed UITestConfig Json parser to use Json { ignoreUnknownKeys = true }
  so unknown fields in ui_test_config.json do not crash parsing on this branch.
… redirects

addDpopJktIfNeeded regenerated a new key pair on every call.  During pool
server login the method is called twice (once for the instance /authorize,
once after the pool-server redirect), so the auth code's dpop_jkt binding
used Key A but the token-exchange proof used Key C → server rejected with
invalid_dpop_proof.

Fix: reuse pendingCredentialsIdentifier if already set within the same login
flow; only generate a new key pair when starting fresh.
…ter README

- @ignore testECAJwtDPoP_ViaLoginPoolServer — server-side bug W-23864247 causes
  invalid_dpop_proof on the authorization-code token exchange at login.test1.pc-rnd.
  Re-enable when server fix is confirmed.
- Add missing tests to DPoPLoginTests table in README (pool server, mixed multi-user)

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

Reviewed as the cross-platform peer to the approved iOS twin (PR 4130, W-23836436), against the design spec (Workspace 76) and a runtime trace of the DPoP /authorize/token key binding.

The production change is correct — this request is narrowly about a self-added test that is red in CI, not about the auth logic. Removing the pool-server carve-out so dpop_jkt is sent to login/test.salesforce.com when DPoP is enabled matches (a) the server team's confirmation in the spec that pool servers now support DPoP authorization-code binding, and (b) the iOS change, which makes the identical relaxation. The key-reuse addition (pendingCredentialsIdentifier) keeps the /authorize dpop_jkt and the token-exchange proof bound to the same key pair across server-picker re-entries — I traced this and could not find a path that publishes a thumbprint for one key and signs with another; on a crypto soft-fail no dpop_jkt is emitted and the token exchange also attaches no proof, so both hops stay self-consistent. No credential-leak or key-mismatch issue found, and the changed line's unit coverage is green.

Requested change (blocking): the PR adds an active test that fails deterministically in CI. ECALoginTests.testECAJwt_ViaLoginPoolServer is new here, not @Ignored, and fails every run of the ui-tests-pr job with loginPoolHost not found in ui_test_config.jsonuseLoginPoolHost = true routes into requireLoginPoolHost(), which throws when the key is absent. The spec (Workspace 76) called for adding loginPoolHost to both the live ui_test_config.json and shared/test/ui_test_config.json.sample; neither landed (the sample still has only loginHosts/apps). Please make the job green by either (a) provisioning loginPoolHost in the CI config and adding it to the .sample, or (b) guarding the test to skip gracefully when the key is absent (as the DPoP pool test does with @Ignore). A PR shouldn't merge adding its own permanently-red test.

Two non-blocking notes:

  1. (Consistency, optional) Android drops the explicit welcome.salesforce.com guard that the iOS twin keeps. I confirmed via the call graph that reloadWebView() handles Welcome Discovery before generateAuthorizationUrl, so addDpopJktIfNeeded is never reached with the discovery host today — the current code is correct and the KDoc explains it well. Consider keeping a one-line defensive welcome check anyway for parity with iOS and resilience if a future caller wires generateAuthorizationUrl differently. Purely optional.

  2. (Affirm) The @Ignore on testECAJwtDPoP_ViaLoginPoolServer for the server-side invalid_dpop_proof bug (W-23864247) is appropriate and matches the iOS XCTSkip. It does not mask a client regression: the client-side contract (sending dpop_jkt on the pool /authorize) is still covered by the green LoginViewModelTest unit test.

For context, the other ui-tests failures in this run are unrelated to this PR — the known Custom Tab / touch-injection infra flakes, plus testECAJwtDPoP_And_NonDPoP_MultiUser_FlagOff_IndependentProofs, a multi-user token-format test that already exists on dev (merged in 6edf6f7) and is not in a line this PR changes. Only testECAJwt_ViaLoginPoolServer is attributable to this PR.

No public API, localization, or SQLCipher surface is touched. This PR sits on OAuth/credential paths (a CLAUDE.md escalation class), so it also warrants a human sign-off on the auth change.

// Login via the pool server without DPoP and verify the session is valid.
@Test
fun testECAJwt_ViaLoginPoolServer() {
loginAndValidate(knownAppConfig = ECA_JWT, useLoginPoolHost = true)

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 test is active (not @Ignored) and fails in the ui-tests-pr CI job for HEAD with java.lang.Exception: loginPoolHost not found in ui_test_config.jsonuseLoginPoolHost = true routes into requireLoginPoolHost() (UITestConfig.kt:86), which throws when the key is absent. The spec (Workspace 76) required adding loginPoolHost to the live ui_test_config.json and to shared/test/ui_test_config.json.sample; the sample here still has only loginHosts/apps. Please either provision loginPoolHost in the CI config and add it to the .sample, or guard this test so it skips gracefully when the key is missing (as the DPoP pool test does with @Ignore), so a green ui-tests run reflects real coverage.

) {
val isMyDomainServer = !LoginServerManager.isPoolServer(server)
if (!sdkManager.useDPoP || !isMyDomainServer) {
if (!sdkManager.useDPoP) {

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.

Optional / for parity with the iOS twin: iOS keeps an explicit early-return for welcome.salesforce.com in its equivalent helper, whereas this relaxation relies solely on the call graph (discovery is handled in reloadWebView() before generateAuthorizationUrl, so this method is never reached with the discovery host). That reasoning is correct today and the KDoc documents it well — this is just a suggestion to add a one-line defensive welcome guard for belt-and-suspenders symmetry with iOS, in case a future caller wires generateAuthorizationUrl differently. Not blocking.

1. tapLoginForAdminsMenuItem: dismiss server picker before tapping overflow
   menu — backOutToLoginActivity leaves the non-dismissable login picker
   showing; the MORE_OPTIONS_BUTTON is hidden while the picker is open so
   the click failed with 'node not found'.

2. testECAJwtDPoP_And_NonDPoP_MultiUser_FlagOff_IndependentProofs: pass
   isJwt=true to switchToUserAndValidateUser and assertRevokeAndRefreshWorks
   for both users — ECA_JWT and ECA_JWT_DPOP both issue JWT tokens so the
   user agent contains JT, not OT; omitting isJwt caused 'Expected OT flag'.
…discovery

setUsername / setPassword / tapLogin were wasting TIMEOUT_MS (15-20 s) on
a UiSelector.resourceId lookup that always fails inside Chrome (HTML element
IDs are not Android resource IDs), then only giving the same TIMEOUT_MS to
the className fallback that actually finds the field.

For a cold Chrome profile (FRE dismissed but Salesforce page still loading),
the combined two-timeout wait (30-40 s) could expire before the login form
appeared, causing intermittent "Username field not found in Custom Tab".

Fix: quick-check resourceId (500 ms), then fall through to className with
the extended WEBVIEW_ACTION_TIMEOUT_MS (45 s local / 60 s FTL).
…review feedback

- ChromeCustomTabPageObject: replace backOutToLoginActivity() with
  waitForLoginScreen() in tapLoginForAdminsMenuItem override, and set
  isBrowserLoginEnabled=false before tapping the server row so reloadWebView()
  loads the in-app WebView instead of relaunching a Custom Tab.
  This fixes the MORE_OPTIONS_BUTTON not found error after W-23731759 made
  the login picker non-dismissable.
- LoginViewModel: add defensive Welcome Discovery guard to addDpopJktIfNeeded
  for belt-and-suspenders symmetry with the iOS twin (optional suggestion from
  review, non-blocking).
- ECALoginTests: @ignore testECAJwt_ViaLoginPoolServer until loginPoolHost is
  provisioned in the CI ui_test_config.json (blocking issue from PR review).

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

Re-reviewed at HEAD c6abcff — both requested changes are resolved, and the DPoP change remains sound. Clearing my earlier REQUEST_CHANGES.

① The blocking issue is fixed. ECALoginTests.testECAJwt_ViaLoginPoolServer is now @Ignored with a comment documenting the re-enable path — the graceful-skip option from my comment. The proof is in CI: ui-tests-pr / test-android is green on this HEAD, where it was deterministically red before. All checks (unit-tests, ui-tests, test-orchestrator, SAST, credential scanning) pass.

② The optional consistency note was adopted. addDpopJktIfNeeded now guards with LoginServerManager.WELCOME_LOGIN_URL == server, bringing Android to parity with the iOS twin's explicit Welcome early-return. I confirmed the guard is live — server is the raw selected-server URL passed into both generateAuthorizationUrl overloads, and it compares by exact equality against the "https://welcome.salesforce.com/discovery" constant.

Nice addition beyond what I asked for: test_givenDPoPEnabled_whenGenerateAuthorizationUrlCalledTwice_thenDpopJktIsStable asserts the dpop_jkt and pendingCredentialsIdentifier stay stable across a pool → my-domain redirect. That directly locks in the key-reuse contract at the heart of W-23836447 (the /authorize binding and the token-exchange proof must use the same key), with real assertions — a good regression guard.

One note, not blocking:

(Follow-up) The skip is the right call for now, but shared/test/ui_test_config.json.sample still doesn't carry loginPoolHost. Consider filing a quick follow-up to provision the key in CI and add it to the .sample, then remove the @Ignore, so the pool-server path regains real e2e coverage and the skip doesn't get forgotten.

…cted (W-23731759)

The login-server picker became a non-dismissable ModalBottomSheet in PR forcedotcom#2983:
its back handler rejects the Hidden state so device back presses are swallowed
while the picker is showing. After backOutToLoginActivity() closes the forced-
advanced-auth Custom Tab, clearWebView(showServerPicker=true) shows the picker;
navigateBackToApp's 4 back presses all land in the sheet and LoginActivity never
finishes.

Fix: add LoginPageObject.exitServerPickerIfShowing() which taps the picker
header's PICKER_LOGIN_BACK_BUTTON — the production-correct way to exit login
when an authenticated user exists (invokes LoginActivity.handleBackBehavior()
→ finish()). Call it in testChangeDynamicConfigWithoutLogin_existingUserUnaffected
after backOutToLoginActivity() and before navigateBackToApp().

Verified: testChangeDynamicConfigWithoutLogin_existingUserUnaffected passes.
MultiUserLoginTests: 17/17 passing, 0 failures.
@wmathurin

Copy link
Copy Markdown
Contributor Author

NB: All UI tests passing locally.

@wmathurin
wmathurin merged commit 638fbea into forcedotcom:dev Aug 15, 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.

2 participants