Skip to content

Add token refresh coordinator#4087

Open
bbirman wants to merge 1 commit into
forcedotcom:devfrom
bbirman:refreshCoordinator
Open

Add token refresh coordinator#4087
bbirman wants to merge 1 commit into
forcedotcom:devfrom
bbirman:refreshCoordinator

Conversation

@bbirman

@bbirman bbirman commented Jun 26, 2026

Copy link
Copy Markdown
Member
  • Adds a layer to coordinate token refreshes so that only a single refresh is requested at a time across SFRestAPI, SFIdentityCoordinator and SFUserAccountManager
  • Consumers calling refresh directly should go through SFUserAccountManager
  • Deprecates SFOAuthSessionRefresher so that it will be internal only in the future

@github-actions

Copy link
Copy Markdown
1 Warning
⚠️ Big PR, try to keep changes smaller if you can.

Generated by 🚫 Danger

@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
SFUserAccountManager Nullability Memory error Null passed to a callee that requires a non-null 2nd parameter 1601 15
SFUserAccountManager Nullability Memory error Null passed to a callee that requires a non-null 2nd parameter 1616 15
SFUserAccountManager Nullability Memory error nil passed to a callee that requires a non-null 2nd parameter 2257 13

Generated by 🚫 Danger

@codecov

codecov Bot commented Jun 26, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 88.76404% with 20 lines in your changes missing coverage. Please review.
✅ Project coverage is 68.46%. Comparing base (29439f0) to head (997c4e0).

Files with missing lines Patch % Lines
...KCore/Classes/OAuth/SFSDKTokenRefreshCoordinator.m 88.17% 11 Missing ⚠️
...SDKCore/Classes/UserAccount/SFUserAccountManager.m 75.00% 3 Missing ⚠️
...forceSDKCore/Classes/RestAPI/WebSocketClient.swift 83.33% 2 Missing ⚠️
...KCore/SalesforceSDKCore/Classes/Util/SFSDKOAuth2.m 66.66% 2 Missing ⚠️
...ceSDKCore/Classes/Identity/SFIdentityCoordinator.m 0.00% 1 Missing ⚠️
...Core/SalesforceSDKCore/Classes/RestAPI/SFRestAPI.m 97.29% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##              dev    #4087      +/-   ##
==========================================
- Coverage   70.73%   68.46%   -2.28%     
==========================================
  Files         246      247       +1     
  Lines       21541    21611      +70     
==========================================
- Hits        15237    14795     -442     
- Misses       6304     6816     +512     
Components Coverage Δ
Analytics 70.78% <ø> (ø)
Common 70.79% <ø> (-0.19%) ⬇️
Core 62.15% <88.76%> (-3.48%) ⬇️
SmartStore 73.60% <ø> (ø)
MobileSync 88.56% <ø> (ø)
Files with missing lines Coverage Δ
...DKCore/Classes/Extensions/UserAccountManager.swift 28.57% <100.00%> (+22.11%) ⬆️
...rceSDKCore/Classes/OAuth/SFOAuthSessionRefresher.m 91.54% <100.00%> (+2.50%) ⬆️
...ceSDKCore/Classes/Identity/SFIdentityCoordinator.m 47.72% <0.00%> (-24.13%) ⬇️
...Core/SalesforceSDKCore/Classes/RestAPI/SFRestAPI.m 89.42% <97.29%> (-0.22%) ⬇️
...forceSDKCore/Classes/RestAPI/WebSocketClient.swift 87.34% <83.33%> (ø)
...KCore/SalesforceSDKCore/Classes/Util/SFSDKOAuth2.m 76.17% <66.66%> (-2.15%) ⬇️
...SDKCore/Classes/UserAccount/SFUserAccountManager.m 54.62% <75.00%> (-7.54%) ⬇️
...KCore/Classes/OAuth/SFSDKTokenRefreshCoordinator.m 88.17% <88.17%> (ø)

... and 25 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

github-actions Bot commented Jun 26, 2026

Copy link
Copy Markdown
TestsPassedSkippedFailed ❌️
AuthFlowTester UI Test Results all1 ran1 ❌
TestResult
AuthFlowTester UI Test Results all
AuthFlowTesterUITests.xctest
LegacyLoginTests.testCAOpaque_DefaultScopes_WebServerFlow()❌ failure

@github-actions

Copy link
Copy Markdown
TestsPassed ☑️SkippedFailed ❌️
SalesforceSDKCore iOS ^26 Test Results694 ran693 ✅1 ❌
TestResult
SalesforceSDKCore iOS ^26 Test Results
SFSDKAuthUtilTests.testSingleRefreshWithRevokedAccessToken()❌ failure

* the callbacks are appended to the waiting list and no new network request is made.
* When the single in-flight refresh completes, all registered callbacks receive the same result.
*
* Completion and error callbacks are dispatched on the main queue.

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.

Thread contract mismatch: This says callbacks are dispatched on the main queue, but completeRefreshForKey:credentials:error: calls them directly on the serial queue — no main-queue hop. The old SFOAuthSessionRefresher.completeWithSuccess wrapped callbacks in dispatch_async(dispatch_get_main_queue(), ...), which this PR removes.

Callers updating UI or expecting main-thread delivery will now run on a background thread. Either dispatch in completeRefreshForKey:, or drop this claim from the header and audit call sites.

});
}
}];
if (self.refreshCycleActive) {

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.

refreshCycleActive is not reset by cleanup, creating a timing gap. If cleanup runs while a refresh is in-flight, refreshCycleActive stays YES. Any new request enqueued after cleanup but before the coordinator callback resets the flag will hit this guard and silently skip triggering a refresh.

testNewRefreshCyclePossibleAfterCleanup avoids this by manually setting refreshCycleActive = NO rather than going through the real timing path. Consider resetting the flag inside cleanup after clearing activeRequests.

}
});
if (completionBlock) {
completionBlock(endpointResponse);

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.

Main-queue removal affects all callers of SFSDKOAuth2, not just the coordinator path. The dispatch_async(dispatch_get_main_queue(), ...) wrapper was removed from error-path completions in both accessTokenForApprovalCode: and accessTokenForRefresh:. Any direct caller of these methods that touches UI in the completion block will now run on a background URL session thread.

XCTAssertNotEqual(account.credentials.accessToken, originalAccessToken,
"Access token should be rotated after refresh")
XCTAssertNotNil(account.credentials.refreshToken)
print("original refresh token: \(originalRefreshToken)")

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.

Debug print() statements — looks unintentional.

let notificationCount = Mutex<Int>(0)

let observer = NotificationCenter.default.addObserver(
forName: .init(rawValue: "SFNotificationOAuthUserDidRefreshToken"),

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.

Fragile raw notification name string. Observing "SFNotificationOAuthUserDidRefreshToken" instead of kSFNotificationUserDidRefreshToken means if the constant value ever changes this test silently vacuously passes. Use the typed constant.


#pragma mark - Integration Test (Mock at authClient level)

- (void)testIntegrationSingleNetworkCallForConcurrentRefreshes {

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.

Integration test assertion may be vacuously true. SFOAuthSessionRefresher constructs its own SFOAuthCoordinator internally and calls accessTokenForRefresh: directly — it does not go through SFUserAccountManager.authClient(). With realCoordinator.refresherFactory = nil (real refresher path), mockClient.accessTokenForRefreshCallCount likely stays 0 regardless of how many network calls go out, making the key assertion (== 1) always pass.

Suggest adding XCTAssertGreaterThan(mockClient.accessTokenForRefreshCallCount, 0, @"mock was never called") before the coalescing assertion to confirm the mock is actually hit.

}
SFOAuthInfo *authInfo = [[SFOAuthInfo alloc] initWithAuthType:SFOAuthTypeRefresh];
[userInfo setValue:authInfo forKey:kSFNotificationUserInfoAuthTypeKey];
[[NSNotificationCenter defaultCenter] postNotificationName:kSFNotificationUserDidRefreshToken

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.

Notification sender has changed. The old refreshCredentials: in SFUserAccountManager posted kSFNotificationUserDidRefreshToken with object:strongSelf (the account manager). Now this posts with object:self (the refresher). Any observer filtering on notification.object — e.g. addObserver:selector:name:object:accountManager — will silently stop receiving notifications.

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