From 47f30e9dee6f9720a2f3ad5e1dcfe26e5f12e8a5 Mon Sep 17 00:00:00 2001 From: Wolfgang Mathurin Date: Tue, 25 Aug 2026 14:01:42 -0700 Subject: [PATCH 1/2] fix(dpop): handle 400 use_dpop_nonce on revoke path after cold restart (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). --- .../Classes/RestAPI/SFRestAPI.m | 16 ++++++++++ .../Classes/RestAPI/SFRestRequest+Internal.h | 3 ++ .../Tests/DPoPLoginTests.swift | 29 ++++++++++++++----- native/SampleApps/AuthFlowTester/README.md | 2 +- 4 files changed, 41 insertions(+), 9 deletions(-) diff --git a/libs/SalesforceSDKCore/SalesforceSDKCore/Classes/RestAPI/SFRestAPI.m b/libs/SalesforceSDKCore/SalesforceSDKCore/Classes/RestAPI/SFRestAPI.m index 3727ad0151..1839e062b7 100644 --- a/libs/SalesforceSDKCore/SalesforceSDKCore/Classes/RestAPI/SFRestAPI.m +++ b/libs/SalesforceSDKCore/SalesforceSDKCore/Classes/RestAPI/SFRestAPI.m @@ -364,6 +364,22 @@ - (void)enqueueRequest:(SFRestRequest *)request shouldRetry:(BOOL)shouldRetry { request.successBlock(dataForDelegate, response); } } else { + // DPoP nonce challenge (HTTP 400 with use_dpop_nonce in body): harvest the + // server-issued nonce and retry the request once with the updated proof. + // This covers the post-restart case where the in-memory nonce cache is empty + // and the first outbound DPoP call (e.g. revoke) triggers a nonce challenge. + // RFC 9449 §8 — the server SHOULD return the desired nonce in DPoP-Nonce. + NSString *bodyStr = data ? [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding] : nil; + if (statusCode == 400 + && !request.dpopNonceRetried + && [bodyStr containsString:SFSDKDPoPRequestDecorator.nonceErrorCode]) { + request.dpopNonceRetried = YES; + [SFSDKDPoPRequestDecorator harvestNonceFromResponse:response + requestURL:finalRequest.URL + scope:strongSelf.user.credentials.identifier]; + [strongSelf enqueueRequest:request shouldRetry:shouldRetry]; + return; + } if (shouldRetry && [strongSelf shouldRetryTask:dataTask withData:data]) { [strongSelf replayRequest:request response:response]; } else { diff --git a/libs/SalesforceSDKCore/SalesforceSDKCore/Classes/RestAPI/SFRestRequest+Internal.h b/libs/SalesforceSDKCore/SalesforceSDKCore/Classes/RestAPI/SFRestRequest+Internal.h index 2ad4cb92fd..bfe16215e6 100644 --- a/libs/SalesforceSDKCore/SalesforceSDKCore/Classes/RestAPI/SFRestRequest+Internal.h +++ b/libs/SalesforceSDKCore/SalesforceSDKCore/Classes/RestAPI/SFRestRequest+Internal.h @@ -37,6 +37,9 @@ NS_ASSUME_NONNULL_BEGIN @property (nonatomic, copy, nullable) SFRestRequestFailBlock failureBlock; @property (nonatomic, copy, nullable) SFRestResponseBlock successBlock; +/// Set to YES after a DPoP nonce-challenge retry so the retry fires at most once per request. +@property (nonatomic, assign) BOOL dpopNonceRetried; + + (nonnull NSString *)restUrlForBaseUrl:(nullable NSString *)baseUrl serviceHostType:(SFSDKRestServiceHostType)hostType credentials:(nonnull SFOAuthCredentials *)credentials; + (NSString *)toQueryString:(nullable NSDictionary *)components; diff --git a/native/SampleApps/AuthFlowTester/AuthFlowTesterUITests/Tests/DPoPLoginTests.swift b/native/SampleApps/AuthFlowTester/AuthFlowTesterUITests/Tests/DPoPLoginTests.swift index 3ad0a1422d..093a0d2fa9 100644 --- a/native/SampleApps/AuthFlowTester/AuthFlowTesterUITests/Tests/DPoPLoginTests.swift +++ b/native/SampleApps/AuthFlowTester/AuthFlowTesterUITests/Tests/DPoPLoginTests.swift @@ -178,16 +178,29 @@ class DPoPLoginTests: BaseAuthFlowTester { // MARK: - Restart - /// Restart app after DPoP login and verify session and keypair persist. + /// Restart app after DPoP login; verify the EC keypair and session survive, and that + /// revoke+refresh works despite the in-memory nonce cache being empty after restart. /// - /// Skipped pending SDK fix: on iOS, revoke after app restart fails because the DPoP - /// nonce cache is in-memory only and there is no nonce-challenge retry on the - /// `RestClient` request path (only the token endpoint retries). Android's equivalent - /// test passes only because its revoke goes over raw OkHttp on the login host, - /// bypassing DPoP entirely — iOS revokes go to the instance host through the - /// DPoP-decorating REST stack. + /// After restart the nonce cache is cold. The first revoke call sends a nonce-less DPoP + /// proof; the server returns HTTP 400 `use_dpop_nonce`. `SFRestAPI.enqueueRequest` now + /// detects this, harvests the server-issued nonce from the response header, and retries + /// the request once with the updated proof (W-23501382). func test_givenDPoPUser_whenAppRestart_thenSessionAndKeypairSurvive() throws { - throw XCTSkip("TODO: Pending SDK fix: RestClient path lacks nonce-challenge retry; post-restart DPoP revoke fails because in-memory nonce cache is empty.") + launchLoginAndValidate( + loginHost: .regularAuth, + user: .third, + staticAppConfigName: .ecaJwtDpop, + useHybridFlow: false, + useDPoP: true + ) + + restartAndValidateUser( + loginHost: .regularAuth, + user: .third, + userAppConfigName: .ecaJwtDpop, + useHybridFlow: false + ) + assertRevokeAndRefreshWorks(isRtr: false, isDPoP: true, useHybridFlow: false, isJwt: true) } // MARK: - Pool Server Login diff --git a/native/SampleApps/AuthFlowTester/README.md b/native/SampleApps/AuthFlowTester/README.md index b85e42a9b0..369c1c60b9 100644 --- a/native/SampleApps/AuthFlowTester/README.md +++ b/native/SampleApps/AuthFlowTester/README.md @@ -67,7 +67,7 @@ All DPoP tests live here — basic login, RTR, multi-user, migration, server enf | `testLogin_DPoP_ECA_Without_DPoP_Fails` | ECA JWT DPoP | — | Server enforcement: DPoP-enforced ECA rejects login without DPoP (`useDPoP: false`); no account created | | `test_givenBearerSession_whenUpgradeToDPoP_thenDPoPBound` | ECA JWT → ECA JWT DPoP | — | Bearer → DPoP in-place upgrade via `UserAccountManager.upgradeToDPoP`; consumer key unchanged, `token_type: "DPoP"` post-upgrade | | `test_givenDPoPSession_whenDowngradeFromDPoP_thenBearerUnbound` | ECA JWT | — | DPoP → Bearer in-place downgrade via `UserAccountManager.downgradeFromDPoP`; consumer key unchanged, `token_type` no longer `"DPoP"` post-downgrade. Runs on the DPoP-*optional* ECA JWT app (a DPoP-*enforced* app would reject the downgrade's unbound `/authorize` request) | -| `test_givenDPoPUser_whenAppRestart_thenSessionAndKeypairSurvive` | ECA JWT DPoP | — | `XCTSkip` (pending SDK fix — RestClient path lacks nonce-challenge retry; post-restart DPoP revoke fails because in-memory nonce cache is empty) | +| `test_givenDPoPUser_whenAppRestart_thenSessionAndKeypairSurvive` | ECA JWT DPoP | — | EC keypair persists in Keychain; revoke succeeds after restart via nonce-challenge retry (W-23501382) | | `test_givenDPoP_whenLoginViaPoolServer_thenTokenTypeIsDPoP` | ECA JWT DPoP | — | Pool server login with DPoP; `dpop_jkt` accepted; L1 (production) marker in UA | | `test_givenDPoPECA_whenAdminLogin_thenDPoPBindingWorksThroughBrowser` | ECA JWT DPoP | — | Login for Admins hand-off to ASWebAuthenticationSession works with DPoP binding | From 7872e12e926a3b58a8e740f39ba2f67049641888 Mon Sep 17 00:00:00 2001 From: Wolfgang Mathurin Date: Tue, 25 Aug 2026 14:23:59 -0700 Subject: [PATCH 2/2] docs(auth): remove internal story reference from AuthFlowTester README --- native/SampleApps/AuthFlowTester/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/native/SampleApps/AuthFlowTester/README.md b/native/SampleApps/AuthFlowTester/README.md index 369c1c60b9..76d668d0ed 100644 --- a/native/SampleApps/AuthFlowTester/README.md +++ b/native/SampleApps/AuthFlowTester/README.md @@ -67,7 +67,7 @@ All DPoP tests live here — basic login, RTR, multi-user, migration, server enf | `testLogin_DPoP_ECA_Without_DPoP_Fails` | ECA JWT DPoP | — | Server enforcement: DPoP-enforced ECA rejects login without DPoP (`useDPoP: false`); no account created | | `test_givenBearerSession_whenUpgradeToDPoP_thenDPoPBound` | ECA JWT → ECA JWT DPoP | — | Bearer → DPoP in-place upgrade via `UserAccountManager.upgradeToDPoP`; consumer key unchanged, `token_type: "DPoP"` post-upgrade | | `test_givenDPoPSession_whenDowngradeFromDPoP_thenBearerUnbound` | ECA JWT | — | DPoP → Bearer in-place downgrade via `UserAccountManager.downgradeFromDPoP`; consumer key unchanged, `token_type` no longer `"DPoP"` post-downgrade. Runs on the DPoP-*optional* ECA JWT app (a DPoP-*enforced* app would reject the downgrade's unbound `/authorize` request) | -| `test_givenDPoPUser_whenAppRestart_thenSessionAndKeypairSurvive` | ECA JWT DPoP | — | EC keypair persists in Keychain; revoke succeeds after restart via nonce-challenge retry (W-23501382) | +| `test_givenDPoPUser_whenAppRestart_thenSessionAndKeypairSurvive` | ECA JWT DPoP | — | EC keypair persists in Keychain; revoke succeeds after restart via nonce-challenge retry | | `test_givenDPoP_whenLoginViaPoolServer_thenTokenTypeIsDPoP` | ECA JWT DPoP | — | Pool server login with DPoP; `dpop_jkt` accepted; L1 (production) marker in UA | | `test_givenDPoPECA_whenAdminLogin_thenDPoPBindingWorksThroughBrowser` | ECA JWT DPoP | — | Login for Admins hand-off to ASWebAuthenticationSession works with DPoP binding |