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..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 | — | `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 | | `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 |