Skip to content

fix(dpop): handle 400 use_dpop_nonce on revoke path after cold restart (W-23501382) - #4145

Merged
wmathurin merged 2 commits into
forcedotcom:devfrom
wmathurin:fix-dpop-nonce-restart
Aug 26, 2026
Merged

fix(dpop): handle 400 use_dpop_nonce on revoke path after cold restart (W-23501382)#4145
wmathurin merged 2 commits into
forcedotcom:devfrom
wmathurin:fix-dpop-nonce-restart

Conversation

@wmathurin

Copy link
Copy Markdown
Contributor

Problem

After a cold app restart the in-memory DPoPNonceCache is empty. The first DPoP-decorated REST call — most commonly the access-token revoke that assertRevokeAndRefreshWorks issues — sends a nonce-less DPoP proof. Salesforce's authorization server replies with HTTP 400 use_dpop_nonce and the desired nonce in the DPoP-Nonce response header.

The existing retry path in SFRestAPI only handles 401 (expired credentials) and 403 Bad_OAuth_Token; it does not handle 400, so the revoke fails and the test throws XCTAssertTrue failed - Failed to revoke access token.

This was the root cause of the prematurely-closed W-23501382.

Fix

SFRestAPI.enqueueRequest — before the existing shouldRetry guard, detect a 400 response whose body contains use_dpop_nonce, harvest the server-issued nonce from the DPoP-Nonce response header into DPoPNonceCache, and re-enqueue the request once with the updated proof.

SFRestRequest+Internal.h — adds dpopNonceRetried BOOL flag so the retry fires at most once per request (prevents looping if the server keeps challenging).

Per RFC 9449 §8 the server SHOULD include the desired nonce in the DPoP-Nonce header of the challenge response; harvestNonceFromResponse:requestURL:scope: is a no-op if the header is absent, so the retry falls through to the normal error path if the server doesn't cooperate.

Test

DPoPLoginTests.test_givenDPoPUser_whenAppRestart_thenSessionAndKeypairSurvive — previously XCTSkip'd — is now enabled and passing:

  1. Login with ECA JWT DPoP (no hybrid), user .third
  2. Cold restart (restart())
  3. restartAndValidateUser — confirms EC keypair and session reloaded from Keychain/disk
  4. assertRevokeAndRefreshWorks — confirms revoke + refresh succeeds despite cold nonce cache
Suite Test Result
DPoPLoginTests test_givenDPoPUser_whenAppRestart_thenSessionAndKeypairSurvive ✅ passed (150s)

Notes

  • This PR is intended to be merged after fix(auth): fix DPoP validation propagation in AuthFlowTester UI tests #4144 (AuthFlowTester test harness fixes), which is being merged separately
  • The fix is scoped to the REST client path — the token endpoint's own nonce-challenge retry (via the 401→refresh→replay path) is unchanged
  • Android's equivalent test passes because its revoke sends to the login host over raw OkHttp, bypassing DPoP; iOS revokes go to the instance host through the DPoP-decorating REST stack, so this path was never exercised on Android

…t (W-23501382)

After an app restart the in-memory DPoP-Nonce cache is empty. The first
DPoP-decorated REST call (e.g. access-token revoke) sends a nonce-less
proof; Salesforce's token endpoint replies with HTTP 400 use_dpop_nonce.
The existing 401-refresh-replay path in SFRestAPI did not cover this case
because shouldRetry only fires on 401/403.

Fix: in SFRestAPI.enqueueRequest, detect a 400 response whose body
contains "use_dpop_nonce", harvest the server-issued DPoP-Nonce from the
response header into DPoPNonceCache, and re-enqueue the request once with
the updated proof. A dpopNonceRetried flag on SFRestRequest prevents any
subsequent challenge from looping.

Enables test_givenDPoPUser_whenAppRestart_thenSessionAndKeypairSurvive
in DPoPLoginTests (previously XCTSkip'd).
@github-actions

Copy link
Copy Markdown
1 Warning
⚠️ Static Analysis found an issue with one or more files you modified. Please fix the issue(s).

Clang Static Analysis Issues

File Type Category Description Line Col
SFRestAPI Nullability Memory error nil assigned to a pointer which is expected to have non-null value 95 5
SFRestAPI Nullability Memory error nil passed to a callee that requires a non-null 1st parameter 431 34

Generated by 🚫 Danger

@wmathurin
wmathurin requested a review from sfdctaka August 25, 2026 21:16
@github-actions

Copy link
Copy Markdown
TestsPassed ☑️SkippedFailed ❌️
SalesforceSDKCore iOS ^18 Test Results1009 ran1008 ✅1 ❌
TestResult
SalesforceSDKCore iOS ^18 Test Results
testLogin❌ failure

@codecov

codecov Bot commented Aug 25, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 71.58%. Comparing base (32e55d2) to head (7872e12).
⚠️ Report is 1 commits behind head on dev.

❌ Your patch check has failed because the patch coverage (40.00%) is below the target coverage (80.00%). You can increase the patch coverage or adjust the target coverage.

Additional details and impacted files
@@            Coverage Diff             @@
##              dev    #4145      +/-   ##
==========================================
- Coverage   71.61%   71.58%   -0.03%     
==========================================
  Files         254      254              
  Lines       22837    22847      +10     
==========================================
+ Hits        16355    16356       +1     
- Misses       6482     6491       +9     
Components Coverage Δ
Analytics 70.78% <ø> (ø)
Common 70.79% <ø> (ø)
Core 67.32% <100.00%> (-0.04%) ⬇️
SmartStore 73.45% <ø> (ø)
MobileSync 88.84% <ø> (ø)
Files with missing lines Coverage Δ
...Core/SalesforceSDKCore/Classes/RestAPI/SFRestAPI.m 89.47% <100.00%> (+0.21%) ⬆️

... and 4 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@github-actions

Copy link
Copy Markdown
TestsPassed ☑️Skipped ⚠️Failed ❌️
AuthFlowTester UI Test Results all110 ran68 ✅3 ⚠️39 ❌
TestResult
AuthFlowTester UI Test Results all
AuthFlowTesterUITests.xctest
BeaconLoginTests.testBeaconJwt_SubsetScopes()❌ failure
BeaconLoginTests.testBeaconOpaque_DefaultScopes()❌ failure
BeaconLoginTests.testBeaconJwt_AllScopes()❌ failure
BeaconLoginTests.testBeaconOpaque_SubsetScopes()❌ failure
BeaconLoginTests.testBeaconJwt_DefaultScopes()❌ failure
BeaconLoginTests.testBeaconOpaque_AllScopes()❌ failure
WelcomeLoginTests.testWelcomeDiscovery_RegularAuthLoginHost_DynamicConfig()❌ failure
WelcomeLoginTests.testWelcomeDiscovery_AdvancedAuthLoginHost_DynamicConfig()❌ failure
WelcomeLoginTests.testWelcomeDiscovery_AdvancedAuthLoginHost()❌ failure
LoginForAdminTests.testLoginForAdmin_WebServerFlowDisabled()❌ failure
AdvancedAuthBeaconLoginTests.testBeaconOpaque_DefaultScopes()❌ failure
AdvancedAuthBeaconLoginTests.testBeaconOpaque_SubsetScopes()❌ failure
AdvancedAuthBeaconLoginTests.testBeaconOpaque_AllScopes()❌ failure
AdvancedAuthBeaconLoginTests.testBeaconJwt_AllScopes()❌ failure
AdvancedAuthBeaconLoginTests.testBeaconJwt_DefaultScopes()❌ failure
AdvancedAuthBeaconLoginTests.testBeaconJwt_SubsetScopes()❌ failure
LoginWithRestartTests.testBeaconJwt_SubsetScopes_DynamicConfiguration_WithRestart()❌ failure
LoginWithRestartTests.testBeaconOpaque_DefaultScopes_WithRestart()❌ failure
LoginWithRestartTests.testBeaconJwt_DefaultScopes_DynamicConfiguration_WithRestart()❌ failure
LoginWithRestartTests.testAdvancedAuth_WithRestart()❌ failure
LoginWithRestartTests.testCAOpaque_DefaultScopes_WithRestart()❌ failure
RefreshTokenMigrationWithRestartTests.testMigrateCAToBeacon_WithRestart()❌ failure
RefreshTokenMigrationWithRestartTests.testMigrateCAToECA_WithRestart()❌ failure
RefreshTokenMigrationWithRestartTests.testMigrateBeaconScopeAddition_WithRestart()❌ failure
RefreshTokenMigrationWithRestartTests.testMigrateMultipleUsers_WithRestart()❌ failure
LegacyLoginTests.testCAOpaque_AllScopes_WebServerFlow()❌ failure
ForceAdvancedAuthTests.testForceAdvancedAuth_AddAdditionalUser_BackButtonAccessible()❌ failure
ForceAdvancedAuthTests.testForceAdvancedAuth_Disabled_BackAndGearStillPresent()❌ failure
RefreshTokenMigrationTests.testFlagDiversity_MigratedBeaconJwtVsNonHybridOpaque()❌ failure
RefreshTokenMigrationTests.testMigrateBeaconToCA()❌ failure
RefreshTokenMigrationTests.testMigrateCAUserAgentToBeaconWebServer()❌ failure
RefreshTokenMigrationTests.testMigrateOneUserOnly()❌ failure
RefreshTokenMigrationTests.testMigrateCAToBeacon()❌ failure
RefreshTokenMigrationTests.testMigrateBeacon_AddMoreScopes()❌ failure
RefreshTokenMigrationTests.testMigrateBeaconOpaqueToJWTAndBack()❌ failure
RefreshTokenMigrationTests.testMigrateCAToBeaconAndBack()❌ failure
MultiUserLoginTests.testAdvancedAuthUser_HasBWFlag_RegularAuthUser_DoesNot()❌ failure
MultiUserLoginTests.testFlagDiversity_BeaconNonHybridJwtVsHybridOpaque()❌ failure
MultiUserLoginTests.testBeaconAndNonBeacon_MultiUser()❌ failure

@sfdctaka sfdctaka 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!

@wmathurin
wmathurin merged commit 39fab10 into forcedotcom:dev Aug 26, 2026
20 of 24 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