Skip to content

Commit ab84f31

Browse files
authored
Merge pull request #4092 from wmathurin/W-23195020-ios-rtr-feature-flag
feat(W-23195020): register RT per-user flag on Refresh Token Rotation detection
2 parents 9de77c7 + feea6d0 commit ab84f31

6 files changed

Lines changed: 172 additions & 7 deletions

File tree

libs/SalesforceSDKCore/SalesforceSDKCore/Classes/Common/SFSDKAppFeatureMarkers.h

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ extern NSString * const kSFAppFeatureAiltnEnabled;
4343
extern NSString * const kSFSPAppFeatureIDPLogin;
4444
extern NSString * const kSFIDPAppFeatureIDPLogin;
4545
extern NSString * const kSFAppFeatureQrCodeLogin;
46+
extern NSString * const kSFAppFeatureRTR;
4647

4748
/**
4849
Class to register and unregister feature markers associated with SDK facilities being used in

libs/SalesforceSDKCore/SalesforceSDKCore/Classes/Common/SFSDKAppFeatureMarkers.m

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@
4141
NSString * const kSFSPAppFeatureIDPLogin = @"SP";
4242
NSString * const kSFIDPAppFeatureIDPLogin = @"IP";
4343
NSString * const kSFAppFeatureQrCodeLogin = @"QR";
44+
NSString * const kSFAppFeatureRTR = @"RT";
4445

4546
static NSMutableSet<NSString *> *SFSDKAppFeatureMarkersSet = nil;
4647
static dispatch_queue_t SFSDKAppFeatureDispatchQueue = nil;

libs/SalesforceSDKCore/SalesforceSDKCore/Classes/OAuth/SFOAuthSessionRefresher.m

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
#import "SFOAuthCredentials+Internal.h"
2828
#import "SFOAuthInfo.h"
2929
#import "SFSDKOAuth2.h"
30+
#import "SFSDKAppFeatureMarkers.h"
3031

3132
@interface SFOAuthSessionRefresher()
3233

@@ -90,9 +91,21 @@ - (void)refreshSessionWithCompletion:(void (^)(SFOAuthCredentials *))completionB
9091
if (response.hasError) {
9192
[strongSelf completeWithError:response.error.error];
9293
} else {
94+
NSString *oldRefreshToken = strongSelf.credentials.refreshToken;
9395
[strongSelf.credentials updateCredentials:[response asDictionary]];
9496
if (response.additionalOAuthFields)
9597
strongSelf.credentials.additionalOAuthFields = response.additionalOAuthFields;
98+
99+
// Detect Refresh Token Rotation: server sent a new, different refresh token
100+
if (strongSelf.credentials.refreshToken.length > 0
101+
&& ![strongSelf.credentials.refreshToken isEqualToString:oldRefreshToken]) {
102+
SFUserAccount *account = [[SFUserAccountManager sharedInstance]
103+
accountForCredentials:strongSelf.credentials];
104+
if (account) {
105+
[SFSDKAppFeatureMarkers registerAppFeature:kSFAppFeatureRTR forUser:account];
106+
}
107+
}
108+
96109
[strongSelf completeWithSuccess];
97110
}
98111
}];

libs/SalesforceSDKCore/SalesforceSDKCoreTests/SFOAuthSessionRefresherTests.m

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,31 @@
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

libs/SalesforceSDKCore/SalesforceSDKCoreTests/SFSDKAppFeatureMarkersTests.m

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ - (void)tearDown {
5959
[SFSDKAppFeatureMarkers unregisterAppFeature:kSFAppFeatureSafariBrowserForLogin forUser:self.userA];
6060
[SFSDKAppFeatureMarkers unregisterAppFeature:kSFAppFeatureWelcomeDiscovery forUser:self.userA];
6161
[SFSDKAppFeatureMarkers unregisterAppFeature:kSFAppFeatureQrCodeLogin forUser:self.userA];
62+
[SFSDKAppFeatureMarkers unregisterAppFeature:kSFAppFeatureRTR forUser:self.userA];
6263
self.userA = nil;
6364
self.userB = nil;
6465
[self clearExistingMarkers];
@@ -274,6 +275,27 @@ - (void)test_givenGlobalQRNotSet_whenPromoteQR_thenUserLacksQR {
274275
@"QR should NOT be per-user when global QR was not set");
275276
}
276277

278+
#pragma mark - Refresh Token Rotation (RTR) flag tests
279+
280+
- (void)test_givenRTRDetected_whenRTFlagRegistered_thenFlagAppearsInPerUserFeaturesNotGlobal {
281+
// Arrange: use userA as the account that experienced token rotation
282+
283+
// Act: simulate RTR detection registering the flag
284+
[SFSDKAppFeatureMarkers registerAppFeature:kSFAppFeatureRTR forUser:self.userA];
285+
286+
// Assert: RT in per-user features (union with global)
287+
NSSet *features = [SFSDKAppFeatureMarkers appFeaturesForUser:self.userA];
288+
XCTAssertTrue([features containsObject:kSFAppFeatureRTR],
289+
@"RT flag should appear in per-user feature set after rotation");
290+
291+
// Assert: RT NOT in global-only set
292+
XCTAssertFalse([[SFSDKAppFeatureMarkers appFeatures] containsObject:kSFAppFeatureRTR],
293+
@"RT flag should not bleed into global feature set");
294+
295+
// Cleanup
296+
[SFSDKAppFeatureMarkers unregisterAppFeature:kSFAppFeatureRTR forUser:self.userA];
297+
}
298+
277299
#pragma mark - Private helpers
278300

279301
- (SFUserAccount *)fakeUserWithOrgId:(NSString *)orgId userId:(NSString *)userId credentialsIdentifier:(NSString *)identifier {

native/SampleApps/AuthFlowTester/AuthFlowTesterUITests/Util/BaseAuthFlowTester.swift

Lines changed: 25 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -609,8 +609,9 @@ class BaseAuthFlowTester: XCTestCase {
609609
/// - expectAdvancedAuth: Whether advanced auth (browser-based) was used, which sets the BW flag. Defaults to `false`.
610610
/// - usesWelcomeDiscovery: Whether welcome domain discovery was used. Defaults to `false`.
611611
/// - isMultiUser: Whether multiple users are currently logged in. Defaults to `false`.
612-
func validateUserAgent(userCredentials: UserCredentialsData, loginHost: KnownLoginHostConfig, expectAdvancedAuth: Bool = false, usesWelcomeDiscovery: Bool = false, isMultiUser: Bool = false) {
613-
validateUserAgent(ua: userCredentials.userAgent, loginHost: loginHost, expectAdvancedAuth: expectAdvancedAuth, usesWelcomeDiscovery: usesWelcomeDiscovery, isMultiUser: isMultiUser)
612+
/// - isRtr: Whether Refresh Token Rotation is enabled, which sets the RT flag. Defaults to `false`.
613+
func validateUserAgent(userCredentials: UserCredentialsData, loginHost: KnownLoginHostConfig, expectAdvancedAuth: Bool = false, usesWelcomeDiscovery: Bool = false, isMultiUser: Bool = false, isRtr: Bool = false) {
614+
validateUserAgent(ua: userCredentials.userAgent, loginHost: loginHost, expectAdvancedAuth: expectAdvancedAuth, usesWelcomeDiscovery: usesWelcomeDiscovery, isMultiUser: isMultiUser, isRtr: isRtr)
614615
}
615616

616617
/// Validates a pre-fetched user agent string. Called from validate() which already has the UA.
@@ -621,7 +622,8 @@ class BaseAuthFlowTester: XCTestCase {
621622
/// - expectAdvancedAuth: Whether advanced auth (browser-based) was used, which sets the BW flag. Defaults to `false`.
622623
/// - usesWelcomeDiscovery: Whether welcome domain discovery was used. Defaults to `false`.
623624
/// - isMultiUser: Whether multiple users are currently logged in. Defaults to `false`.
624-
private func validateUserAgent(ua: String, loginHost: KnownLoginHostConfig, expectAdvancedAuth: Bool = false, usesWelcomeDiscovery: Bool = false, isMultiUser: Bool = false) {
625+
/// - isRtr: Whether Refresh Token Rotation is enabled, which sets the RT flag. Defaults to `false`.
626+
private func validateUserAgent(ua: String, loginHost: KnownLoginHostConfig, expectAdvancedAuth: Bool = false, usesWelcomeDiscovery: Bool = false, isMultiUser: Bool = false, isRtr: Bool = false) {
625627
XCTAssertTrue(ua.contains("SalesforceMobileSDK/"), "User agent should contain 'SalesforceMobileSDK/' prefix; got: \(ua)")
626628
XCTAssertTrue(ua.contains("ftr_"), "User agent should contain 'ftr_' feature flag segment; got: \(ua)")
627629

@@ -643,10 +645,22 @@ class BaseAuthFlowTester: XCTestCase {
643645

644646
if usesWelcomeDiscovery {
645647
XCTAssertTrue(flagSet.contains("WD"), "User agent should contain 'WD' flag when welcome discovery is used; flags: \(flagSet), ua: \(ua)")
648+
} else {
649+
XCTAssertFalse(flagSet.contains("WD"), "User agent should NOT contain 'WD' flag when welcome discovery is not used; flags: \(flagSet), ua: \(ua)")
646650
}
647651

648652
if isMultiUser {
649653
XCTAssertTrue(flagSet.contains("MU"), "User agent should contain 'MU' flag when multiple users are logged in; flags: \(flagSet), ua: \(ua)")
654+
} else {
655+
XCTAssertFalse(flagSet.contains("MU"), "User agent should NOT contain 'MU' flag when only one user is logged in; flags: \(flagSet), ua: \(ua)")
656+
}
657+
658+
if isRtr {
659+
XCTAssertTrue(flagSet.contains("RT"),
660+
"User agent should contain 'RT' flag after Refresh Token Rotation; flags: \(flagSet), ua: \(ua)")
661+
} else {
662+
XCTAssertFalse(flagSet.contains("RT"),
663+
"User agent should NOT contain 'RT' flag when Refresh Token Rotation has not occurred; flags: \(flagSet), ua: \(ua)")
650664
}
651665
}
652666

@@ -757,7 +771,7 @@ class BaseAuthFlowTester: XCTestCase {
757771

758772
// Revoke and refresh cycle
759773
let userAppConfig = getAppConfig(named: userAppConfigName)
760-
assertRevokeAndRefreshWorks(previousCredentials: userCredentials, isRtr: userAppConfig.isRtr)
774+
assertRevokeAndRefreshWorks(previousCredentials: userCredentials, isRtr: userAppConfig.isRtr, loginHost: loginHost)
761775

762776
// Check the oauth configuration
763777
_ = checkOauthConfiguration(
@@ -886,11 +900,11 @@ class BaseAuthFlowTester: XCTestCase {
886900
}
887901

888902
/// Captures current credentials then performs a revoke/refresh cycle and validates the result.
889-
func assertRevokeAndRefreshWorks(isRtr: Bool) {
890-
assertRevokeAndRefreshWorks(previousCredentials: getUserCredentials(), isRtr: isRtr)
903+
func assertRevokeAndRefreshWorks(isRtr: Bool, loginHost: KnownLoginHostConfig = .regularAuth) {
904+
assertRevokeAndRefreshWorks(previousCredentials: getUserCredentials(), isRtr: isRtr, loginHost: loginHost)
891905
}
892906

893-
private func assertRevokeAndRefreshWorks(previousCredentials: UserCredentialsData, isRtr: Bool) {
907+
private func assertRevokeAndRefreshWorks(previousCredentials: UserCredentialsData, isRtr: Bool, loginHost: KnownLoginHostConfig = .regularAuth) {
894908
// Revoke access token
895909
XCTAssert(mainPage.revokeAccessToken(), "Failed to revoke access token")
896910

@@ -920,6 +934,10 @@ class BaseAuthFlowTester: XCTestCase {
920934
"Refresh token should not have changed (non-RTR app)"
921935
)
922936
}
937+
938+
validateUserAgent(userCredentials: credentialsAfterRefresh,
939+
loginHost: loginHost,
940+
isRtr: isRtr)
923941
}
924942

925943
private func sortedScopes(_ value: String) -> String {

0 commit comments

Comments
 (0)