Skip to content

Commit 1401c1b

Browse files
authored
Merge pull request #4116 from sfdctaka/feature/rtr-dev-info-screen
Surface refresh token rotation state in dev info screen
2 parents 25342ca + f68e170 commit 1401c1b

8 files changed

Lines changed: 151 additions & 2 deletions

File tree

libs/SalesforceSDKCore/SalesforceSDKCore/Classes/Common/SalesforceSDKManager.m

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -555,6 +555,19 @@ - (NSString *)devInfoTitleString
555555
return [actions copy];
556556
}
557557

558+
static NSString *SFSDKISO8601StringFromDate(NSDate *date) {
559+
if (!date) return nil;
560+
static NSDateFormatter *formatter = nil;
561+
static dispatch_once_t onceToken;
562+
dispatch_once(&onceToken, ^{
563+
formatter = [[NSDateFormatter alloc] init];
564+
formatter.locale = [NSLocale localeWithLocaleIdentifier:@"en_US_POSIX"];
565+
formatter.timeZone = [NSTimeZone timeZoneWithName:@"UTC"];
566+
formatter.dateFormat = @"yyyy-MM-dd'T'HH:mm:ss'Z'";
567+
});
568+
return [formatter stringFromDate:date];
569+
}
570+
558571
- (NSArray<NSString *>*) getDevSupportInfos
559572
{
560573
SFUserAccountManager* userAccountManager = [SFUserAccountManager sharedInstance];
@@ -640,7 +653,31 @@ - (NSString *)devInfoTitleString
640653
[devInfos addObjectsFromArray:@[@"Managed", [managedPreferences hasManagedPreferences] ? @"YES" : @"NO"]];
641654
[devInfos addObjectsFromArray:[self dictToDevInfos:managedPreferences.rawPreferences]];
642655
}
643-
656+
657+
// section:RTR — refresh-token-rotation observability
658+
[devInfos addObject:@"section:RTR"];
659+
{
660+
SFUserAccount *rtrUser = [SFUserAccountManager sharedInstance].currentUser;
661+
NSString *rtrActive;
662+
if (!rtrUser) {
663+
rtrActive = @"N/A";
664+
} else {
665+
NSSet<NSString *> *features = [SFSDKAppFeatureMarkers appFeaturesForUser:rtrUser];
666+
rtrActive = [features containsObject:kSFAppFeatureRTR] ? @"YES" : @"NO";
667+
}
668+
NSString *lastRotation = @"Never";
669+
if (rtrUser.credentials.lastTokenRotationDate) {
670+
NSString *iso = SFSDKISO8601StringFromDate(rtrUser.credentials.lastTokenRotationDate);
671+
if (iso.length > 0) {
672+
lastRotation = iso;
673+
}
674+
}
675+
[devInfos addObjectsFromArray:@[
676+
@"RTR Active", rtrActive,
677+
@"Last Rotation", lastRotation
678+
]];
679+
}
680+
644681
return devInfos;
645682
}
646683

libs/SalesforceSDKCore/SalesforceSDKCore/Classes/OAuth/SFOAuthCredentials+Internal.h

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ extern NSException * _Nullable SFOAuthInvalidIdentifierException(void);
6363
@property (nonatomic, readwrite, nullable) NSString *communityId;
6464
@property (nonatomic, readwrite, nullable) NSURL *communityUrl;
6565
@property (nonatomic, readwrite, nullable) NSDate *issuedAt;
66+
@property (nonatomic, readwrite, nullable) NSDate *lastTokenRotationDate;
6667
@property (nonatomic, readwrite, nullable) NSURL *identityUrl;
6768
@property (nonatomic, readwrite, nullable) NSURL *apiUrl;
6869
@property (nonatomic, readwrite, nullable) NSString *userId;

libs/SalesforceSDKCore/SalesforceSDKCore/Classes/OAuth/SFOAuthCredentials.h

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -190,11 +190,20 @@ NS_SWIFT_NAME(OAuthCredentials)
190190
@property (nonatomic, readonly, nullable) NSURL *communityUrl;
191191

192192
/** The timestamp when the session access token was issued.
193-
193+
194194
This property is set by the `SFOAuthCoordinator` after authentication has successfully completed.
195195
*/
196196
@property (nonatomic, readonly, nullable) NSDate *issuedAt;
197197

198+
/** The timestamp of the most recent refresh-token rotation observed for this credential.
199+
200+
Set by `SFOAuthSessionRefresher` when a token refresh returns a `refresh_token`
201+
that differs from the previously-stored one. `nil` if no rotation has been observed
202+
for this credential (or if this credential predates the field's introduction — older
203+
archives decode this field as `nil`).
204+
*/
205+
@property (nonatomic, readonly, nullable) NSDate *lastTokenRotationDate;
206+
198207
/** The identity URL for the user returned as part of a successful authentication response.
199208
The format of the URL is: _https://login.salesforce.com/ID/orgID/userID_ where orgId is the ID of the Salesforce organization
200209
that the user belongs to, and userID is the Salesforce user ID.

libs/SalesforceSDKCore/SalesforceSDKCore/Classes/OAuth/SFOAuthCredentials.m

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ @implementation SFOAuthCredentials
6565
@synthesize apiInstanceUrl = _apiInstanceUrl;
6666
@synthesize scopes = _scopes;
6767
@synthesize issuedAt = _issuedAt;
68+
@synthesize lastTokenRotationDate = _lastTokenRotationDate;
6869
@synthesize protocol = _protocol;
6970
@synthesize encrypted = _encrypted;
7071
@synthesize additionalOAuthFields = _additionalOAuthFields;
@@ -98,6 +99,7 @@ - (id)initWithCoder:(NSCoder *)coder {
9899
self.communityId = [coder decodeObjectOfClass:[NSString class] forKey:@"SFOAuthCommunityId"];
99100
self.communityUrl = [coder decodeObjectOfClass:[NSURL class] forKey:@"SFOAuthCommunityUrl"];
100101
self.issuedAt = [coder decodeObjectOfClass:[NSDate class] forKey:@"SFOAuthIssuedAt"];
102+
self.lastTokenRotationDate = [coder decodeObjectOfClass:[NSDate class] forKey:@"SFOAuthLastTokenRotationDate"];
101103
self.additionalOAuthFields = [coder decodeObjectOfClasses:[NSSet setWithObjects:[NSDictionary class], [NSString class], nil] forKey:@"SFOAuthAdditionalFields"];
102104
NSString *protocolVal = [coder decodeObjectOfClass:[NSString class] forKey:@"SFOAuthProtocol"];
103105
if (nil != protocolVal) {
@@ -152,6 +154,7 @@ - (void)encodeWithCoder:(NSCoder *)coder {
152154
[coder encodeObject:self.communityId forKey:@"SFOAuthCommunityId"];
153155
[coder encodeObject:self.communityUrl forKey:@"SFOAuthCommunityUrl"];
154156
[coder encodeObject:self.issuedAt forKey:@"SFOAuthIssuedAt"];
157+
[coder encodeObject:self.lastTokenRotationDate forKey:@"SFOAuthLastTokenRotationDate"];
155158
[coder encodeObject:self.protocol forKey:@"SFOAuthProtocol"];
156159
[coder encodeObject:self.lightningDomain forKey:@"SFOAuthLightningDomain"];
157160
[coder encodeObject:self.vfDomain forKey:@"SFOAuthVFDomain"];
@@ -213,6 +216,7 @@ - (id)copyWithZone:(nullable NSZone *)zone {
213216
copyCreds.communityId = self.communityId;
214217
copyCreds.communityUrl = self.communityUrl;
215218
copyCreds.issuedAt = self.issuedAt;
219+
copyCreds.lastTokenRotationDate = self.lastTokenRotationDate;
216220

217221
// NB: Intentionally ordering the copying of these, because setting the identity URL automatically
218222
// sets the OrgID and UserID. This ensures the values stay in sync.

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,7 @@ - (void)executeRefreshWithAttestation:(NSString * _Nullable)attestation {
121121
SFUserAccount *account = [[SFUserAccountManager sharedInstance]
122122
accountForCredentials:strongSelf.credentials];
123123
if (account) {
124+
strongSelf.credentials.lastTokenRotationDate = [NSDate date];
124125
[SFSDKAppFeatureMarkers registerAppFeature:kSFAppFeatureRTR forUser:account];
125126
}
126127
}

libs/SalesforceSDKCore/SalesforceSDKCoreTests/SFOAuthSessionRefresherTests.m

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,11 @@ - (void)test_givenRotatedRefreshToken_whenRefreshSucceeds_thenRTFlagRegisteredPe
188188
XCTAssertTrue([features containsObject:kSFAppFeatureRTR],
189189
@"RT flag should be registered after refresh token rotation");
190190

191+
// Assert: timestamp was stamped
192+
XCTAssertNotNil(account.credentials.lastTokenRotationDate, @"Expected rotation timestamp to be stamped");
193+
XCTAssertLessThan(fabs([account.credentials.lastTokenRotationDate timeIntervalSinceNow]), 5.0,
194+
@"Rotation timestamp should be within 5 seconds of now");
195+
191196
// Cleanup
192197
[SFUserAccountManager sharedInstance].authClient = originalFactory;
193198
[SFSDKAppFeatureMarkers unregisterAppFeature:kSFAppFeatureRTR forUser:account];
@@ -200,6 +205,9 @@ - (void)test_givenUnchangedRefreshToken_whenRefreshSucceeds_thenRTFlagNotRegiste
200205
SFUserAccount *account = [[SFUserAccount alloc] initWithCredentials:creds];
201206
[[SFUserAccountManager sharedInstance] saveAccountForUser:account error:nil];
202207

208+
// Pre-seed a known prior timestamp
209+
account.credentials.lastTokenRotationDate = [NSDate dateWithTimeIntervalSince1970:1234567890];
210+
203211
NSDictionary *responseDict = @{
204212
kSFOAuthAccessToken: @"new_access_token",
205213
kSFOAuthRefreshToken: creds.refreshToken, // same token — no rotation
@@ -227,6 +235,10 @@ - (void)test_givenUnchangedRefreshToken_whenRefreshSucceeds_thenRTFlagNotRegiste
227235
XCTAssertFalse([features containsObject:kSFAppFeatureRTR],
228236
@"RT flag should not be registered when refresh token did not rotate");
229237

238+
// Assert: timestamp preserved
239+
XCTAssertEqualWithAccuracy([account.credentials.lastTokenRotationDate timeIntervalSince1970], 1234567890, 0.001,
240+
@"Rotation timestamp must be preserved when no rotation occurred");
241+
230242
// Cleanup
231243
[SFUserAccountManager sharedInstance].authClient = originalFactory;
232244
[[SFUserAccountManager sharedInstance] deleteAccountForUser:account error:nil];

libs/SalesforceSDKCore/SalesforceSDKCoreTests/SalesforceOAuthUnitTests.m

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,7 @@ - (void)testCredentialsCoding {
133133
credsIn.apiInstanceUrl = [NSURL URLWithString:@"http://api.salesforce.com"];
134134
credsIn.scopes = @[@"api", @"refresh_token"];
135135
credsIn.issuedAt = [NSDate date];
136+
credsIn.lastTokenRotationDate = [NSDate dateWithTimeIntervalSince1970:1700000000];
136137
credsIn.contentDomain = @"mobilesdk.my.salesforce.com";
137138
credsIn.contentSid = @"contentsid";
138139
credsIn.lightningDomain = @"mobilesdk.lightning.force.com";
@@ -178,6 +179,7 @@ - (void)testCredentialsCoding {
178179
XCTAssertEqualObjects(credsIn.apiInstanceUrl, credsOut.apiInstanceUrl, @"apiInstanceUrl mismatch");
179180
XCTAssertEqualObjects(credsIn.scopes, credsOut.scopes, @"scopes mismatch");
180181
XCTAssertEqualObjects(credsIn.issuedAt, credsOut.issuedAt, @"issuedAt mismatch");
182+
XCTAssertEqualObjects(credsIn.lastTokenRotationDate, credsOut.lastTokenRotationDate, @"lastTokenRotationDate mismatch");
181183
XCTAssertEqualObjects(credsIn.contentDomain, credsOut.contentDomain, @"contentDomain mismatch");
182184
XCTAssertEqualObjects(credsIn.contentSid, credsOut.contentSid, @"contentSid mismatch");
183185
XCTAssertEqualObjects(credsIn.lightningDomain, credsOut.lightningDomain, @"lightningDomain mismatch");
@@ -196,6 +198,21 @@ - (void)testCredentialsCoding {
196198
XCTAssertEqualObjects(credsIn.additionalOAuthFields, credsOut.additionalOAuthFields, @"additionalFields mismatch");
197199

198200
credsIn = nil;
201+
202+
// Test nil round-trip for lastTokenRotationDate
203+
SFOAuthKeychainCredentials *nilCredsIn = [[SFOAuthKeychainCredentials alloc] initWithIdentifier:kIdentifier clientId:kClientId encrypted:YES];
204+
// Deliberately NOT setting lastTokenRotationDate
205+
206+
NSKeyedArchiver *nilArchiver = [[NSKeyedArchiver alloc] initRequiringSecureCoding:NO];
207+
[nilArchiver encodeObject:nilCredsIn forKey:@"creds"];
208+
[nilArchiver finishEncoding];
209+
NSData *nilData = nilArchiver.encodedData;
210+
211+
NSKeyedUnarchiver *nilUnarchiver = [[NSKeyedUnarchiver alloc] initForReadingFromData:nilData error:nil];
212+
nilUnarchiver.requiresSecureCoding = YES;
213+
SFOAuthCredentials *nilCredsOut = [nilUnarchiver decodeObjectOfClass:[SFOAuthCredentials class] forKey:@"creds"];
214+
215+
XCTAssertNil(nilCredsOut.lastTokenRotationDate, @"lastTokenRotationDate should be nil when not set");
199216
}
200217

201218
- (void)testCredentialsCopying {
@@ -211,6 +228,7 @@ - (void)testCredentialsCopying {
211228
NSString *communityIdToCheck = @"communityID";
212229
NSURL *communityUrlToCheck = [NSURL URLWithString:@"https://mycomm.my.salesforce.com/customers"];
213230
NSDate *issuedAtToCheck = [NSDate date];
231+
NSDate *lastTokenRotationDateToCheck = [NSDate dateWithTimeIntervalSince1970:1700000000];
214232
NSURL *identityUrlToCheck = [NSURL URLWithString:@"https://login.salesforce.com/id/someOrg/someUser"];
215233
NSString *userIdToCheck = @"userID";
216234
NSString *contentDomainToCheck = @"mobilesdk.my.salesforce.com";
@@ -242,6 +260,7 @@ - (void)testCredentialsCopying {
242260
origCreds.communityId = communityIdToCheck;
243261
origCreds.communityUrl = communityUrlToCheck;
244262
origCreds.issuedAt = issuedAtToCheck;
263+
origCreds.lastTokenRotationDate = lastTokenRotationDateToCheck;
245264
origCreds.contentDomain = contentDomainToCheck;
246265
origCreds.contentSid = contentSidToCheck;
247266
origCreds.lightningDomain = lightningDomainToCheck;
@@ -280,6 +299,7 @@ - (void)testCredentialsCopying {
280299
origCreds.communityId = nil;
281300
origCreds.communityUrl = nil;
282301
origCreds.issuedAt = nil;
302+
origCreds.lastTokenRotationDate = nil;
283303
origCreds.identityUrl = nil;
284304
origCreds.userId = nil;
285305
origCreds.contentDomain = nil;
@@ -341,6 +361,8 @@ - (void)testCredentialsCopying {
341361
XCTAssertNotEqual(origCreds.communityUrl, copiedCreds.communityUrl);
342362
XCTAssertEqual(copiedCreds.issuedAt, issuedAtToCheck);
343363
XCTAssertNotEqual(origCreds.issuedAt, copiedCreds.issuedAt);
364+
XCTAssertEqual(copiedCreds.lastTokenRotationDate, lastTokenRotationDateToCheck);
365+
XCTAssertNotEqual(origCreds.lastTokenRotationDate, copiedCreds.lastTokenRotationDate);
344366
XCTAssertEqual(copiedCreds.identityUrl, identityUrlToCheck);
345367
XCTAssertNotEqual(origCreds.identityUrl, copiedCreds.identityUrl);
346368
XCTAssertEqual(copiedCreds.userId, userIdToCheck);

libs/SalesforceSDKCore/SalesforceSDKCoreTests/SalesforceSDKManagerTests.m

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1743,4 +1743,67 @@ - (void)test_givenNoCurrentUser_whenGetDevSupportInfosInvoked_thenNoCurrentUserS
17431743
XCTAssertFalse(hasDPoPThumbprint, @"Dev support infos should not contain DPoP Key Thumbprint when not logged in");
17441744
}
17451745

1746+
#pragma mark - Dev Support Infos Tests - RTR
1747+
1748+
- (void)test_givenRTRActiveWithTimestamp_whenGetDevSupportInfosInvoked_thenRTRSectionShowsYESAndTimestamp {
1749+
[self createTestAppIdentity];
1750+
SFUserAccount *user = [self createUserAccount];
1751+
NSDate *rotation = [NSDate dateWithTimeIntervalSince1970:1700000000]; // 2023-11-14T22:13:20Z
1752+
user.credentials.lastTokenRotationDate = rotation;
1753+
[[SFUserAccountManager sharedInstance] setCurrentUserInternal:user];
1754+
[SFSDKAppFeatureMarkers registerAppFeature:kSFAppFeatureRTR forUser:user];
1755+
1756+
NSArray<NSString *> *infos = [[SalesforceSDKManager sharedManager] getDevSupportInfos];
1757+
1758+
NSUInteger sectionIdx = [infos indexOfObject:@"section:RTR"];
1759+
XCTAssertNotEqual(sectionIdx, NSNotFound, @"section:RTR must be present");
1760+
XCTAssertEqualObjects(infos[[infos indexOfObject:@"RTR Active"] + 1], @"YES");
1761+
XCTAssertEqualObjects(infos[[infos indexOfObject:@"Last Rotation"] + 1], @"2023-11-14T22:13:20Z");
1762+
1763+
[SFSDKAppFeatureMarkers unregisterAppFeature:kSFAppFeatureRTR forUser:user];
1764+
[[SFUserAccountManager sharedInstance] setCurrentUserInternal:nil];
1765+
}
1766+
1767+
- (void)test_givenRTRNotActive_whenGetDevSupportInfosInvoked_thenRTRSectionShowsNOAndNever {
1768+
[self createTestAppIdentity];
1769+
SFUserAccount *user = [self createUserAccount];
1770+
[[SFUserAccountManager sharedInstance] setCurrentUserInternal:user];
1771+
// Do NOT register kSFAppFeatureRTR; do NOT set lastTokenRotationDate
1772+
1773+
NSArray<NSString *> *infos = [[SalesforceSDKManager sharedManager] getDevSupportInfos];
1774+
1775+
XCTAssertNotEqual([infos indexOfObject:@"section:RTR"], NSNotFound);
1776+
XCTAssertEqualObjects(infos[[infos indexOfObject:@"RTR Active"] + 1], @"NO");
1777+
XCTAssertEqualObjects(infos[[infos indexOfObject:@"Last Rotation"] + 1], @"Never");
1778+
1779+
[[SFUserAccountManager sharedInstance] setCurrentUserInternal:nil];
1780+
}
1781+
1782+
- (void)test_givenNoCurrentUser_whenGetDevSupportInfosInvoked_thenRTRSectionShowsNAAndNever {
1783+
[self createTestAppIdentity];
1784+
[[SFUserAccountManager sharedInstance] setCurrentUserInternal:nil];
1785+
1786+
NSArray<NSString *> *infos = [[SalesforceSDKManager sharedManager] getDevSupportInfos];
1787+
1788+
XCTAssertNotEqual([infos indexOfObject:@"section:RTR"], NSNotFound);
1789+
XCTAssertEqualObjects(infos[[infos indexOfObject:@"RTR Active"] + 1], @"N/A");
1790+
XCTAssertEqualObjects(infos[[infos indexOfObject:@"Last Rotation"] + 1], @"Never");
1791+
}
1792+
1793+
- (void)test_givenRTRActiveButNoTimestamp_whenGetDevSupportInfosInvoked_thenLastRotationIsNever {
1794+
[self createTestAppIdentity];
1795+
SFUserAccount *user = [self createUserAccount];
1796+
[[SFUserAccountManager sharedInstance] setCurrentUserInternal:user];
1797+
[SFSDKAppFeatureMarkers registerAppFeature:kSFAppFeatureRTR forUser:user];
1798+
// Deliberately NOT setting lastTokenRotationDate
1799+
1800+
NSArray<NSString *> *infos = [[SalesforceSDKManager sharedManager] getDevSupportInfos];
1801+
1802+
XCTAssertEqualObjects(infos[[infos indexOfObject:@"RTR Active"] + 1], @"YES");
1803+
XCTAssertEqualObjects(infos[[infos indexOfObject:@"Last Rotation"] + 1], @"Never");
1804+
1805+
[SFSDKAppFeatureMarkers unregisterAppFeature:kSFAppFeatureRTR forUser:user];
1806+
[[SFUserAccountManager sharedInstance] setCurrentUserInternal:nil];
1807+
}
1808+
17461809
@end

0 commit comments

Comments
 (0)