2828#import " SFOAuthCoordinator+Internal.h"
2929#import " SFUserAccount+Internal.h"
3030#import " SFOAuthCredentials+Internal.h"
31+ #import " SFSDKOAuth2+Internal.h"
32+ #import " SFSDKAppFeatureMarkers.h"
33+ #import " SFSDKOAuthConstants.h"
34+
35+ // Expose the private initializer used in production code.
36+ @interface SFSDKOAuthTokenEndpointResponse ()
37+ - (instancetype )initWithDictionary : (NSDictionary *)nvPairs parseAdditionalFields : (NSArray <NSString *> *)additionalOAuthParameterKeys ;
38+ @end
39+
40+ // Minimal SFSDKOAuthProtocol stub that immediately calls the completion block with a preset response.
41+ @interface SFSDKOAuthClientStub : NSObject <SFSDKOAuthProtocol>
42+ @property (nonatomic , strong ) SFSDKOAuthTokenEndpointResponse *stubbedResponse;
43+ @end
44+
45+ @implementation SFSDKOAuthClientStub
46+ - (void )accessTokenForRefresh : (SFSDKOAuthTokenEndpointRequest *)endpointReq
47+ completion : (void (^)(SFSDKOAuthTokenEndpointResponse *))completionBlock {
48+ completionBlock (self.stubbedResponse );
49+ }
50+ - (void )accessTokenForApprovalCode : (SFSDKOAuthTokenEndpointRequest *)endpointReq
51+ completion : (void (^)(SFSDKOAuthTokenEndpointResponse *))completionBlock {}
52+ - (void )openIDTokenForRefresh : (SFSDKOAuthTokenEndpointRequest *)endpointReq
53+ completion : (void (^)(NSString *))completionBlock {}
54+ - (void )revokeRefreshToken : (SFOAuthCredentials *)credentials reason : (SFLogoutReason)reason {}
55+ @end
3156
3257@interface SFOAuthSessionRefresherTests : XCTestCase
3358
@@ -123,6 +148,87 @@ - (void)testFailedRefresh {
123148 }];
124149}
125150
151+ - (void )test_givenRotatedRefreshToken_whenRefreshSucceeds_thenRTFlagRegisteredPerUser {
152+ // Arrange: register a user account whose credentials match the refresher's.
153+ SFOAuthCredentials *creds = self.oauthSessionRefresher .credentials ;
154+ SFUserAccount *account = [[SFUserAccount alloc ] initWithCredentials: creds];
155+ [[SFUserAccountManager sharedInstance ] saveAccountForUser: account error: nil ];
156+
157+ NSString *newRefreshToken = [NSString stringWithFormat: @" rotated_token_%u " , arc4random ()];
158+ NSDictionary *responseDict = @{
159+ kSFOAuthAccessToken : @" new_access_token" ,
160+ kSFOAuthRefreshToken : newRefreshToken,
161+ };
162+ SFSDKOAuthTokenEndpointResponse *response = [[SFSDKOAuthTokenEndpointResponse alloc ]
163+ initWithDictionary: responseDict
164+ parseAdditionalFields: nil ];
165+ SFSDKOAuthClientStub *stub = [[SFSDKOAuthClientStub alloc ] init ];
166+ stub.stubbedResponse = response;
167+ SFAuthClientFactoryBlock originalFactory = [SFUserAccountManager sharedInstance ].authClient ;
168+ [SFUserAccountManager sharedInstance ].authClient = ^{ return stub; };
169+
170+ // Pre-condition: RT flag not set
171+ [SFSDKAppFeatureMarkers unregisterAppFeature: kSFAppFeatureRTR forUser: account];
172+
173+ XCTestExpectation *expectation = [self expectationWithDescription: @" Refresh with rotated token" ];
174+ [self .oauthSessionRefresher refreshSessionWithCompletion: ^(SFOAuthCredentials *updatedCredentials) {
175+ [expectation fulfill ];
176+ } error: ^(NSError *error) {
177+ XCTFail (@" Refresh should not fail: %@ " , error);
178+ [expectation fulfill ];
179+ }];
180+
181+ [self waitForExpectationsWithTimeout: 2.0 handler: nil ];
182+
183+ // Assert: RT flag registered for the user
184+ NSSet *features = [SFSDKAppFeatureMarkers appFeaturesForUser: account];
185+ XCTAssertTrue ([features containsObject: kSFAppFeatureRTR ],
186+ @" RT flag should be registered after refresh token rotation" );
187+
188+ // Cleanup
189+ [SFUserAccountManager sharedInstance ].authClient = originalFactory;
190+ [SFSDKAppFeatureMarkers unregisterAppFeature: kSFAppFeatureRTR forUser: account];
191+ [[SFUserAccountManager sharedInstance ] deleteAccountForUser: account error: nil ];
192+ }
193+
194+ - (void )test_givenUnchangedRefreshToken_whenRefreshSucceeds_thenRTFlagNotRegistered {
195+ // Arrange: same refresh token in response — no rotation
196+ SFOAuthCredentials *creds = self.oauthSessionRefresher .credentials ;
197+ SFUserAccount *account = [[SFUserAccount alloc ] initWithCredentials: creds];
198+ [[SFUserAccountManager sharedInstance ] saveAccountForUser: account error: nil ];
199+
200+ NSDictionary *responseDict = @{
201+ kSFOAuthAccessToken : @" new_access_token" ,
202+ kSFOAuthRefreshToken : creds.refreshToken , // same token — no rotation
203+ };
204+ SFSDKOAuthTokenEndpointResponse *response = [[SFSDKOAuthTokenEndpointResponse alloc ]
205+ initWithDictionary: responseDict
206+ parseAdditionalFields: nil ];
207+ SFSDKOAuthClientStub *stub = [[SFSDKOAuthClientStub alloc ] init ];
208+ stub.stubbedResponse = response;
209+ SFAuthClientFactoryBlock originalFactory = [SFUserAccountManager sharedInstance ].authClient ;
210+ [SFUserAccountManager sharedInstance ].authClient = ^{ return stub; };
211+
212+ XCTestExpectation *expectation = [self expectationWithDescription: @" Refresh without rotation" ];
213+ [self .oauthSessionRefresher refreshSessionWithCompletion: ^(SFOAuthCredentials *updatedCredentials) {
214+ [expectation fulfill ];
215+ } error: ^(NSError *error) {
216+ XCTFail (@" Refresh should not fail: %@ " , error);
217+ [expectation fulfill ];
218+ }];
219+
220+ [self waitForExpectationsWithTimeout: 2.0 handler: nil ];
221+
222+ // Assert: RT flag NOT registered
223+ NSSet *features = [SFSDKAppFeatureMarkers appFeaturesForUser: account];
224+ XCTAssertFalse ([features containsObject: kSFAppFeatureRTR ],
225+ @" RT flag should not be registered when refresh token did not rotate" );
226+
227+ // Cleanup
228+ [SFUserAccountManager sharedInstance ].authClient = originalFactory;
229+ [[SFUserAccountManager sharedInstance ] deleteAccountForUser: account error: nil ];
230+ }
231+
126232#pragma mark - Private methods
127233
128234- (void )setupCoordinatorFlow {
@@ -135,6 +241,10 @@ - (void)setupCoordinatorFlow {
135241 creds.instanceUrl = [NSURL URLWithString: @" https://cs1.salesforce.com" ];
136242 creds.accessToken = credsAccessToken;
137243 creds.refreshToken = credsRefreshToken;
244+ // Set userId and orgId as valid 15-char Salesforce entity IDs so matchesCredentials: can compare them.
245+ // (sfsdk_entityId18 returns nil for non-conforming strings, making isEqualToString:nil == NO.)
246+ creds.userId = @" 005000000000001" ;
247+ creds.organizationId = @" 00D000000000001" ;
138248 self.oauthSessionRefresher = [[SFOAuthSessionRefresher alloc ] initWithCredentials: creds];
139249}
140250
0 commit comments