Skip to content

Commit bfe7361

Browse files
Port forcedotcom#4092: iOS Refresh-Token-Rotation (RTR) feature flag (upstream ab84f31, unit 37/49)
Add the 'RT' app-feature marker. During a successful session refresh, SFOAuthSessionRefresher now compares the server-returned refresh token against the pre-refresh token; if it rotated, the RT flag is registered per-user for the owning account so the User-Agent advertises RTR. New global constant kSFAppFeatureRTR = "RT" in SFSDKAppFeatureMarkers. Ported into the compiled Swift twins (SFSDKAppFeatureMarkers.swift + SFOAuthSessionRefresher.swift, using UserAccountManager.userAccount(for:)); de-ref .h/.m ref-synced to the upstream post-image. Tests: both .m twins ref-synced; the new tests also ported into the compiled .swift twins. SFOAuthSessionRefresherTests gains an in-test SFSDKOAuthProtocol stub swapped in via the authClient factory var and 2 RTR tests (rotated vs unchanged token); SFSDKAppFeatureMarkersTests gains 1 per-user RTR test. Sample BaseAuthFlowTester (UITest) ref-synced. SDKCore builds green (0 new warnings); 22 tests pass. Escalation: feature-flag + OAuth token-refresh path -> PR-flag. Advances upstream-sync marker 9de77c7 -> ab84f31.
1 parent 6f7ba15 commit bfe7361

13 files changed

Lines changed: 319 additions & 12 deletions

.claude/pr-escalation-digest.md

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -159,8 +159,22 @@ Pre-approved gates (still PR-flag, do NOT re-ask): SQLCipher #4096; Localizable.
159159
all 4 tests pass (fixture-based). Escalation = thread-safety / concurrency on a user-account accessor —
160160
flag in PR.
161161

162+
- **Unit 37 · #4092 · `ab84f31bd` — feature-flag + OAuth/token-refresh.** Adds the iOS Refresh-Token-Rotation
163+
(RTR) feature flag `RT`. During a successful session refresh, `SFOAuthSessionRefresher` now compares the
164+
refresh token returned by the server against the pre-refresh token; if it changed (server rotated the refresh
165+
token), the `RT` app-feature marker is registered **per-user** for the account that owns those credentials, so
166+
the user's User-Agent advertises RTR. New global constant `kSFAppFeatureRTR = "RT"` in `SFSDKAppFeatureMarkers`.
167+
Ported into the compiled Swift twins (SFSDKAppFeatureMarkers.swift, SFOAuthSessionRefresher.swift); de-ref
168+
`.h`/`.m` ref-synced. **Reviewer notes:** (1) production behavior is exactly upstream's — the flag is only set
169+
when the token actually rotates, never on an unchanged token (both cases covered by new tests). (2) Tests ported
170+
to the migrated surface: the new refresher tests use an in-test `SFSDKOAuthProtocol` stub swapped in via the
171+
`UserAccountManager.shared.authClient` factory var, the internal `SFSDKOAuthTokenEndpointResponse(dictionary:parseAdditionalFields:)`
172+
initializer (`@testable`), and `upsert(_:)`/`delete(_:)` for account save/delete (upstream used
173+
`saveAccountForUser:`/`deleteAccountForUser:`, which live only on the persister in the migrated surface). No
174+
live-org dependency. SDKCore builds green (0 new warnings); 22 tests pass (2 new RTR refresher tests + 1 new
175+
per-user RTR marker test). Escalation = feature-flag + OAuth token-refresh path — flag in PR.
176+
162177
## Pending escalation units (upcoming — port in order)
163-
- **37 · #4092 — feature-flag/OAuth:** iOS RTR feature flag (SFOAuthSessionRefresher).
164178
- **38 · #4094 — OAuth:** OAuth error-code enum (new SFOAuthErrorCode.swift, SFSDKOAuth2, SFOAuthCoordinator).
165179
- **39 · #4093 — PUBLIC API + advanced-auth default flip + L10n(pre-appr):** make advanced-auth the default & deprecate `forceAdvancedAuthentication`; 24 files incl. Localizable.strings.
166180
- **40 · #4088 — login-host + L10n(pre-appr):** invalid login-host recovery; Localizable.strings.

.claude/upstream-sync-backlog.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# Upstream Sync Backlog Ledger
22

3-
Marker (done floor): `9de77c7e2` (units 1-36 done) · forcedotcom/dev HEAD (target): `b5d37d807` · **13 units remaining** · Re-seeded: 2026-07-19 (Phase 0 of the resume-porting plan).
3+
Marker (done floor): `ab84f31bd` (units 1-37 done) · forcedotcom/dev HEAD (target): `b5d37d807` · **12 units remaining** · Re-seeded: 2026-07-19 (Phase 0 of the resume-porting plan).
44

55
> **Direction:** we port changes **FROM** `forcedotcom/dev` **INTO** our ObjC→Swift migration branch
66
> (`feature/objc-to-swift-test-migration`). Each unit is a *semantic re-implementation* against the current
@@ -17,7 +17,7 @@ Marker (done floor): `9de77c7e2` (units 1-36 done) · forcedotcom/dev HEAD (ta
1717
> non-libs (CI, docs, skills, sample apps). Live progress bar = subject of lead task #9.
1818
1919
## Migration status
20-
▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓░░░░░ 36/49 units done (73%) · libs-production-impacting: 11/21 · Phase 1 porting (units 1-36 ✅)
20+
▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓░░░░░ 37/49 units done (76%) · libs-production-impacting: 12/21 · Phase 1 porting (units 1-37 ✅)
2121

2222
| Bucket | Count | Notes |
2323
|--------|-------|-------|
@@ -67,7 +67,7 @@ Marker (done floor): `9de77c7e2` (units 1-36 done) · forcedotcom/dev HEAD (ta
6767
| 34 | #4090 fix iOS18/macOS15 runner | 6a8a47717 | F | ⚠ build-system/CI | ✅ done (commit 6ed7ed5f24e1722316111a0a035942d68e9838d4) | CI-only, no compile gate. Pins the iOS18/Xcode16 matrix leg to the `macos-15` runner (iOS18 sim unavailable on macos-latest/26). 3 files byte-match upstream post-image: `nightly.yaml`, `pr.yaml`, `ui-test-nightly.yaml` — each adds `macos: macos-15` to the `ios:^18/xcode:^16` matrix include + threads `macos: ${{ matrix.macos }}` into the reusable-workflow `with:` calls (test/build/ui-test jobs). The 3 reusable workflows already declared the `macos` input (verified) → no change needed there. Escalation pre-approved in Phase-0 batch; digest entry added. |
6868
| 35 | #4086 feature flags per user | 99a173b58 | B | ⚠⚠ feature-flags + login/OAuth + multi-lib | ✅ done (commit 85e7a1c5a6161fa85ad9479fcc331a7afa4113ca) | Per-user feature flags across Core/MobileSync/SmartStore. Ported into compiled Swift twins: **SFSDKAppFeatureMarkers.swift** (+`registerAppFeature(_:forUser:)`/`unregisterAppFeature(_:forUser:)`/`appFeatures(forUser:)`/`loadPersistedFeatures(_:forUser:)` + per-user `[String:Set<String>]` map keyed by `SFKeyForUserAndScope(user,.user)`; persist via `UserAccountManager.shared.upsert(_:)` — migration's manager-level save, NOT the persister-only `saveAccount(forUser:)`); **SalesforceSDKManager.swift** (new public `userAgent(qualifier:for:)` =`@objc(userAgentString:forUser:)`; `defaultUserAgentString` delegates to it; new `hydratePerUserFeatureFlags()` called in `sharedInstance` lazy init); **SFUserAccount.swift** (`persistedFeatureFlags: Set<String>?` + NSCoding encode/decode w/ `kUser_FEATURE_FLAGS`); **SFUserAccountManager.swift** (bio-auth/screen-lock register `forUser:` + `finalizeAuthCompletion` BW/WD/QR per-user promotion block); **SFSmartStore.swift** + **SFMobileSyncSyncManager.swift** (per-user register one-liners). De-ref .m ref-synced: SFUserAccount.m + SFMobileSyncSyncManager.m (matched pre-image→post-image verbatim); SFSmartStore.m (1-line hunk); SFSDKAppFeatureMarkers.h/.m + SalesforceSDKManager.h/+Internal.h + SFUserAccountManager.m = migration stubs, NO matching region (upstream changes live in Swift twins). **`SalesforceSDKCore.podspec` NOT modified** — upstream's change was pure auto-gen `public_header_files` REORDERING (header set byte-identical pre/post; file is generated by update_podspec_headers.sh) → no material change to port, podspec left untouched (CLAUDE.md podspec rule respected). Tests: NEW SFSDKAppFeatureMarkersTests.m ref-synced (216 lines) + 13 new tests ADDED to compiled .swift twin (per-user + auth-promotion); +1 roundtrip test each to SFUserAccountManagerTests + 3 per-user-UA tests to SalesforceSDKManagerTests (.m ref-synced + .swift twin extended, adapted `upsert`/`delete` for save/delete). Sample AuthFlowTester: 9 files ref-synced to post-image (UserCredentialsView.swift kept migration's `idData?.username` optional-chaining). **3 schemes TEST BUILD SUCCEEDED (SDKCore+SmartStore+MobileSync, 0 err, 0 new warnings); 17 AppFeatureMarkers + 4 manager/UA tests PASS (fixture-based, not live-gated).** ⚠⚠ escalation: feature-flags + login/OAuth (finalizeAuthCompletion) + new public API `userAgent(qualifier:for:)` → digest + PR-flag. |
6969
| 36 | #4091 notification-types thread safety | 9de77c7e2 | B | ⚠ thread-safety | ✅ done (commit 0faf8dfb9c54a0cbd4445cedaca8045ed40fbf70) | Thread-safety fix for `SFUserAccount.notificationTypes` (the last unguarded accessor). Ported into the compiled twin **SFUserAccount.swift**: `notificationTypes` getter now reads `_notificationTypes` under `syncQueue.sync`, setter writes under `syncQueue.async(flags: .barrier)` with `newValue?.map { $0 }` snapshot copy — matches the existing `accessScopes`/`credentials` idiom in the same file (upstream used `dispatch_sync`/`dispatch_barrier_async` on `_syncQueue`). De-ref **SFUserAccount.m** ref-synced: matched pre-image → overwrote with post-image verbatim (`dispatch_sync`/`dispatch_barrier_async` guards). NEW test **SFUserAccountThreadSafetyTests.swift** (upstream is already `.swift`; class `UserAccountThreadSafetyTests`, 4 tests) added byte-faithful, wired additively into pbxproj (build-file `42996EA8…`, file-ref `0DB02650…`, group + Sources phase; plutil OK). **1 test-only migration adaptation:** setUp line 36 `OAuthCredentials(identifier:clientId:encrypted:true)!` → `OAuthCredentials.credentials(identifier:…)!` — the migrated base `OAuthCredentials` convenience init returns nil for `.keychain` (class-cluster replaced by the factory method that builds `OAuthKeychainCredentials`); same class-cluster→factory adaptation used in unit 35's test twin. SDKCore TEST BUILD SUCCEEDED (0 err, 0 new warnings; the `oldKey` warning at SFUserAccount.swift:482 is pre-existing, outside my diff). All 4 UserAccountThreadSafetyTests PASS (fixture-based, not live-gated). ⚠ thread-safety → digest + PR-flag. |
70-
| 37 | #4092 iOS RTR feature flag | ab84f31bd | B | ⚠ feature-flag/OAuth | ⬜ pending | `SFSDKAppFeatureMarkers.*`, `SFOAuthSessionRefresher.m` + tests + sample. |
70+
| 37 | #4092 iOS RTR feature flag | ab84f31bd | B | ⚠ feature-flag/OAuth | ✅ done (commit 57820a68ccae1e7606071551620e270a7e320037) | iOS Refresh-Token-Rotation (RTR) feature flag `RT`. New global constant `kSFAppFeatureRTR = "RT"` added to compiled twin **SFSDKAppFeatureMarkers.swift** (+ de-ref `.h`/`.m` ref-synced: both matched pre-image, applied the 1-line const hunk). **SFOAuthSessionRefresher.swift** (compiled twin): in the refresh success branch, capture `oldRefreshToken` before `credentials.update(...)`, then if the new refresh token is non-empty AND `!= oldRefreshToken`, resolve the account via `UserAccountManager.shared.userAccount(for:)` (migrated `accountForCredentials:`) and `SFSDKAppFeatureMarkers.registerAppFeature(kSFAppFeatureRTR, forUser:)` — detects server-side token rotation and tags the user's UA. De-ref `SFOAuthSessionRefresher.m` ref-synced: matched pre-image → applied import + else-branch hunk surgically (kept migration's existing `-Swift.h` import). Tests: both `.m`s ref-synced to post-image (matched pre-image → verbatim); the NEW tests ALSO ported into the compiled `.swift` twins — **SFOAuthSessionRefresherTests.swift**: added a file-scope `SFSDKOAuthClientStub: NSObject, SFSDKOAuthProtocol` (immediate-completion stub), setUp userId/orgId 15-char entity IDs (so `matchesCredentials:` can compare), + 2 tests (`test_givenRotatedRefreshToken…` / `test_givenUnchangedRefreshToken…`) using the migrated `SFSDKOAuthTokenEndpointResponse(dictionary:parseAdditionalFields:)`, `UserAccountManager.shared.upsert/delete`, and the `authClient` factory var (upstream's `saveAccountForUser:`/`deleteAccountForUser:` → `upsert`/`delete`, `authClient` property assignment kept); **SFSDKAppFeatureMarkersTests.swift**: +RTR tearDown line + 1 per-user RTR test. Sample `BaseAuthFlowTester.swift` (UITest, not lib-compiled) ref-synced to post-image (matched pre-image → verbatim): `isRtr` param threaded through `validateUserAgent`, WD/MU/RT negative-assertions, revoke-refresh cycle passes loginHost + validates UA. No pbxproj change (both `.swift` twins already compiled). SDKCore TEST BUILD SUCCEEDED (0 err; 3 warnings all pre-existing, outside my diff — refresher:84 nil-coalescing, markers:59/67 unused-sync). 22 tests PASS (4 refresher incl. 2 new RTR + 18 AppFeatureMarkers incl. new RTR; fixture-based, not live-gated). ⚠ feature-flag/OAuth (SFOAuthSessionRefresher) → digest + PR-flag. |
7171
| 38 | #4094 OAuth error-code enum | 6993d6ba8 | B+D | ⚠ OAuth | ⬜ pending | `SFOAuthCoordinator.m`, NEW `SFOAuthErrorCode.swift`, `SFSDKOAuth2.h/.m`, constants + tests + pbxproj. |
7272
| 39 | #4093 make advanced-auth default + deprecate forceAdvancedAuthentication | a2c4ee4d5 | B | ⚠⚠⚠ PUBLIC API + advanced-auth + L10n(pre-appr) | ⬜ pending | 24 files: `SalesforceSDKManager.*`, LoginHost VCs, `SFOAuthCoordinator.m`, `SFUserAccountManager.m` + tests + **Localizable.strings** + sample. PUBLIC-API deprecation + default behavior flip. |
7373
| 40 | #4088 invalid login-host recovery | a2a271cca | B | ⚠ login-host + L10n(pre-appr) | ⬜ pending | `NewLoginHostView.swift`, `SFUserAccountManager.m` + tests + pbxproj + **Localizable.strings**. |

.claude/upstream-sync-marker

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
9de77c7e29a0ae4b0c34b930f457868e903006b7
1+
ab84f31bdbcdea948692ce4f9c3aefd2d460262a

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,5 +22,6 @@ extern NSString * const kSFAppFeatureAiltnEnabled;
2222
extern NSString * const kSFSPAppFeatureIDPLogin;
2323
extern NSString * const kSFIDPAppFeatureIDPLogin;
2424
extern NSString * const kSFAppFeatureQrCodeLogin;
25+
extern NSString * const kSFAppFeatureRTR;
2526

2627
NS_ASSUME_NONNULL_END

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@
3939
NSString * const kSFSPAppFeatureIDPLogin = @"SP";
4040
NSString * const kSFIDPAppFeatureIDPLogin = @"IP";
4141
NSString * const kSFAppFeatureQrCodeLogin = @"QR";
42+
NSString * const kSFAppFeatureRTR = @"RT";
4243

4344
// Implementation moved to SFSDKAppFeatureMarkers.swift
4445
// This file retained only for C-linkage constant definitions.

libs/SalesforceSDKCore/SalesforceSDKCore/Classes/Common/SFSDKAppFeatureMarkers.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ public let kSFAppFeatureAiltnEnabled: String = "AI"
4040
public let kSFSPAppFeatureIDPLogin: String = "SP"
4141
public let kSFIDPAppFeatureIDPLogin: String = "IP"
4242
public let kSFAppFeatureQrCodeLogin: String = "QR"
43+
public let kSFAppFeatureRTR: String = "RT"
4344

4445
// MARK: - SFSDKAppFeatureMarkers
4546

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
#import <SalesforceSDKCore/SalesforceSDKCore-Swift.h>
3132

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

libs/SalesforceSDKCore/SalesforceSDKCore/Classes/OAuth/SFOAuthSessionRefresher.swift

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,10 +91,20 @@ public class SFOAuthSessionRefresher: NSObject, SFOAuthCoordinatorDelegate {
9191
if response.hasError {
9292
self.completeWithError(response.error?.error ?? NSError(domain: kSFOAuthErrorDomain, code: -1, userInfo: nil))
9393
} else {
94+
let oldRefreshToken = self.credentials?.refreshToken
9495
self.credentials?.update(response.asDictionary() as? [AnyHashable: Any] ?? [:])
9596
if let additionalFields = response.additionalOAuthFields as? [AnyHashable: Any] {
9697
self.credentials?.setValue(additionalFields, forKey: "additionalOAuthFields")
9798
}
99+
100+
// Detect Refresh Token Rotation: server sent a new, different refresh token
101+
if let newRefreshToken = self.credentials?.refreshToken, newRefreshToken.count > 0,
102+
newRefreshToken != oldRefreshToken,
103+
let creds = self.credentials,
104+
let account = UserAccountManager.shared.userAccount(for: creds) {
105+
SFSDKAppFeatureMarkers.registerAppFeature(kSFAppFeatureRTR, forUser: account)
106+
}
107+
98108
self.completeWithSuccess()
99109
}
100110
}

0 commit comments

Comments
 (0)