Skip to content

Commit b812b48

Browse files
authored
Merge pull request forcedotcom#3949 from wmathurin/migrate_refresh_token
Method to migrate refresh token
2 parents c3b9d38 + f212fe7 commit b812b48

File tree

14 files changed

+585
-8
lines changed

14 files changed

+585
-8
lines changed

libs/SalesforceSDKCore/SalesforceSDKCore.xcodeproj/project.pbxproj

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,8 @@
8080
4F2410B1282DCA4B00E5EFE3 /* SFSDKCollectionResponse.m in Sources */ = {isa = PBXBuildFile; fileRef = 4F2410B0282DCA4B00E5EFE3 /* SFSDKCollectionResponse.m */; };
8181
4F3139652331C5A1007B3705 /* SFSDKAuthRootController.m in Sources */ = {isa = PBXBuildFile; fileRef = 4F3139642331C5A1007B3705 /* SFSDKAuthRootController.m */; };
8282
4F3139682331C5C7007B3705 /* SFSDKAuthRootController.h in Headers */ = {isa = PBXBuildFile; fileRef = 4F3139672331C5B9007B3705 /* SFSDKAuthRootController.h */; };
83+
4F3ECD8A2EBBD150005020A6 /* SFOAuthCoordinatorTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 4F3ECD892EBBD150005020A6 /* SFOAuthCoordinatorTests.m */; };
84+
4F3ECD8C2EBBD182005020A6 /* SFOAuthInfoTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 4F3ECD8B2EBBD182005020A6 /* SFOAuthInfoTests.m */; };
8385
4F5727E327F27F1A0008CDA4 /* SFSDKPrimingRecordsResponse.h in Headers */ = {isa = PBXBuildFile; fileRef = 4F5727DC27F27F1A0008CDA4 /* SFSDKPrimingRecordsResponse.h */; settings = {ATTRIBUTES = (Public, ); }; };
8486
4F5727E427F27F1A0008CDA4 /* SFSDKPrimingRecordsResponse.m in Sources */ = {isa = PBXBuildFile; fileRef = 4F5727E227F27F1A0008CDA4 /* SFSDKPrimingRecordsResponse.m */; };
8587
4F5A49502E98711600C89DDD /* ScopeParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F5A494F2E98711600C89DDD /* ScopeParser.swift */; };
@@ -600,6 +602,8 @@
600602
4F2410B0282DCA4B00E5EFE3 /* SFSDKCollectionResponse.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = SFSDKCollectionResponse.m; sourceTree = "<group>"; };
601603
4F3139642331C5A1007B3705 /* SFSDKAuthRootController.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = SFSDKAuthRootController.m; sourceTree = "<group>"; };
602604
4F3139672331C5B9007B3705 /* SFSDKAuthRootController.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = SFSDKAuthRootController.h; sourceTree = "<group>"; };
605+
4F3ECD892EBBD150005020A6 /* SFOAuthCoordinatorTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = SFOAuthCoordinatorTests.m; sourceTree = "<group>"; };
606+
4F3ECD8B2EBBD182005020A6 /* SFOAuthInfoTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = SFOAuthInfoTests.m; sourceTree = "<group>"; };
603607
4F5727DC27F27F1A0008CDA4 /* SFSDKPrimingRecordsResponse.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = SFSDKPrimingRecordsResponse.h; sourceTree = "<group>"; };
604608
4F5727E227F27F1A0008CDA4 /* SFSDKPrimingRecordsResponse.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = SFSDKPrimingRecordsResponse.m; sourceTree = "<group>"; };
605609
4F5A494F2E98711600C89DDD /* ScopeParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScopeParser.swift; sourceTree = "<group>"; };
@@ -1046,6 +1050,8 @@
10461050
4F7EB3F61BFFC84700768720 /* SalesforceSDKCoreTests */ = {
10471051
isa = PBXGroup;
10481052
children = (
1053+
4F3ECD8B2EBBD182005020A6 /* SFOAuthInfoTests.m */,
1054+
4F3ECD892EBBD150005020A6 /* SFOAuthCoordinatorTests.m */,
10491055
4F5A49572E98B0F800C89DDD /* ScopeParserTests.swift */,
10501056
23F200AB2E551C890091C5F5 /* ActionTypeTests.swift */,
10511057
23F200AD2E551C890091C5F5 /* BootconfigTests.swift */,
@@ -1132,8 +1138,7 @@
11321138
6990CBBF29F5AF56004A5F8D /* SFSDKIDPAuthCodeLoginRequestCommandTest.m */,
11331139
23CAB0F82DCBE51F00B8929B /* Mocks */,
11341140
);
1135-
name = SalesforceSDKCoreTests;
1136-
path = SalesforceSDKCore;
1141+
path = SalesforceSDKCoreTests;
11371142
sourceTree = "<group>";
11381143
};
11391144
4F7EB4571BFFC9D900768720 /* Supporting Files */ = {
@@ -2244,6 +2249,7 @@
22442249
23D96B762E145B400004B06A /* DomainDiscoveryCoordinatorTests.swift in Sources */,
22452250
B7A4AE4922E8CA780060E737 /* SFSDKAuthUtilTests.swift in Sources */,
22462251
69DFE06C2B969C25000906E4 /* PushNotificationDecryptionTests.swift in Sources */,
2252+
4F3ECD8C2EBBD182005020A6 /* SFOAuthInfoTests.m in Sources */,
22472253
4F7EB4161BFFC8D700768720 /* SDKCommonNSDataTests.m in Sources */,
22482254
CE81A9C81E9C26F900F3D0AD /* SFUserAccountManagerNotificationsTests.m in Sources */,
22492255
23F200AC2E551C890091C5F5 /* ActionTypeTests.swift in Sources */,
@@ -2268,6 +2274,7 @@
22682274
69CEBC7E22F368CF00F16218 /* SFNetworkTests.m in Sources */,
22692275
4F7EB41B1BFFC8D700768720 /* SFSDKCryptoUtilsTests.m in Sources */,
22702276
69848CB82364035300893E57 /* SFSDKEncryptedPushNotificationTests.m in Sources */,
2277+
4F3ECD8A2EBBD150005020A6 /* SFOAuthCoordinatorTests.m in Sources */,
22712278
4F9E05322DD6A08000548985 /* SFSDKOAuthTokenEndpointResponseTests.m in Sources */,
22722279
4F06AF8D1C49A18E00F70798 /* SalesforceSDKManagerTests.m in Sources */,
22732280
237C186C2E44FCAE0008015C /* EncryptStreamTests.swift in Sources */,

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,11 @@ NS_ASSUME_NONNULL_BEGIN
8383
*/
8484
- (NSString *)scopeQueryParamString;
8585

86+
/**
87+
Migrates the refresh token for a user to a new app configuration.
88+
*/
89+
- (void)migrateRefreshToken:(SFUserAccount *)user;
90+
8691
@end
8792

8893
NS_ASSUME_NONNULL_END

libs/SalesforceSDKCore/SalesforceSDKCore/Classes/OAuth/SFOAuthCoordinator.m

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -519,6 +519,28 @@ - (void)swapJWTWithCompletionHandler:(void (^)(NSData *data, NSURLResponse *resp
519519
[[self.session dataTaskWithRequest:request completionHandler:completionHandler] resume];
520520
}
521521

522+
// Refresh token migration
523+
- (void)migrateRefreshToken:(SFUserAccount *)user {
524+
self.authInfo = [[SFOAuthInfo alloc] initWithAuthType:SFOAuthTypeRefreshTokenMigration];
525+
self.initialRequestLoaded = NO;
526+
527+
// Use the single access bridge API to get a front door URL for the new app
528+
NSURL *approvalUrl = [NSURL URLWithString:[self generateApprovalUrlString]];
529+
NSString *approvalPath = [[approvalUrl path] stringByAppendingString:approvalUrl.query ? [@"?" stringByAppendingString:approvalUrl.query] : @""];
530+
531+
SFRestRequest* singleAccessRequest = [[SFRestAPI sharedInstanceWithUser:user] requestForSingleAccess:approvalPath];
532+
__weak typeof (self) weakSelf = self;
533+
[[SFRestAPI sharedInstanceWithUser:user] sendRequest:singleAccessRequest failureBlock:^(id response, NSError *error, NSURLResponse *rawResponse) {
534+
if (self.authSession.authFailureCallback) {
535+
self.authSession.authFailureCallback(self.authInfo, error);
536+
}
537+
} successBlock:^(id response, NSURLResponse *rawResponse) {
538+
__strong typeof (self) strongSelf = weakSelf;
539+
NSString *frontDoorUrlString = ((NSDictionary*) response)[@"frontdoor_uri"];
540+
[strongSelf loadWebViewWithUrlString:frontDoorUrlString cookie:YES];
541+
}];
542+
}
543+
522544
// IDP related
523545
- (void)beginIDPFlow:(SFUserAccount *)user success:(void(^)(void))successBlock failure:(void(^)(NSError *))failureBlock {
524546
self.authInfo = [[SFOAuthInfo alloc] initWithAuthType:SFOAuthTypeIDP];

libs/SalesforceSDKCore/SalesforceSDKCore/Classes/OAuth/SFOAuthInfo.h

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ typedef NS_ENUM(NSUInteger, SFOAuthType) {
3838
SFOAuthTypeIDP,
3939
SFOAuthTypeWebServer,
4040
SFOAuthTypeNative,
41+
SFOAuthTypeRefreshTokenMigration,
4142
} NS_SWIFT_NAME(AuthInfo.AuthType);
4243

4344
/**

libs/SalesforceSDKCore/SalesforceSDKCore/Classes/OAuth/SFOAuthInfo.m

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,8 +62,15 @@ - (NSString *)authTypeDescription
6262
case SFOAuthTypeJwtTokenExchange:
6363
desc = @"SFOAuthTypeJwtTokenExchange";
6464
break;
65+
case SFOAuthTypeIDP:
66+
desc = @"SFOAuthTypeIDP";
67+
break;
6568
case SFOAuthTypeNative:
6669
desc = @"SFOAuthTypeNative";
70+
break;
71+
case SFOAuthTypeRefreshTokenMigration:
72+
desc = @"SFOAuthTypeRefreshTokenMigration";
73+
break;
6774
case SFOAuthTypeUnknown:
6875
default:
6976
desc = @"SFOAuthTypeUnknown";

libs/SalesforceSDKCore/SalesforceSDKCore/Classes/UserAccount/SFUserAccountManager+Internal.h

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -216,6 +216,8 @@ Set this block to handle presentation of the Authentication View Controller.
216216
frontDoorBridgeUrl:(nullable NSURL * )frontDoorBridgeUrl
217217
codeVerifier:(nullable NSString *)codeVerifier;
218218

219+
- (SFSDKAuthRequest *)migrateRefreshAuthRequest:(SFSDKAppConfig *)newAppConfig;
220+
219221
@end
220222

221223
NS_ASSUME_NONNULL_END

libs/SalesforceSDKCore/SalesforceSDKCore/Classes/UserAccount/SFUserAccountManager.h

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -571,6 +571,23 @@ Use this method to stop/clear any authentication which is has already been start
571571
*/
572572
- (void)logoutAllUsers;
573573

574+
/**
575+
Migrates the refresh token for the specified user to a new app configuration.
576+
577+
This might cause the approve/deny screen to be presented to the user to authorize the
578+
new app. If successful a new set of credentials (refresh token, access token) are obtained
579+
and replace the existing credentials for the user.
580+
581+
@param user The user account whose refresh token should be migrated.
582+
@param newAppConfig The new app configuration to migrate to.
583+
@param completionBlock Called on successful migration with the updated user account and auth info.
584+
@param failureBlock Called if the migration fails with an error and optional auth info.
585+
*/
586+
- (void)migrateRefreshToken:(SFUserAccount *)user
587+
newAppConfig:(SFSDKAppConfig *)newAppConfig
588+
success:(SFUserAccountManagerSuccessCallbackBlock)completionBlock
589+
failure:(SFUserAccountManagerFailureCallbackBlock)failureBlock NS_SWIFT_NAME(migrateRefreshToken(for:newAppConfig:success:failure:));
590+
574591
/**
575592
Handle an authentication response from the IDP application
576593
@param url The URL response returned to the app from the IDP application.

libs/SalesforceSDKCore/SalesforceSDKCore/Classes/UserAccount/SFUserAccountManager.m

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -573,6 +573,16 @@ -(SFSDKAuthRequest *)defaultAuthRequest {
573573
return [self defaultAuthRequestWithLoginHost:nil];
574574
}
575575

576+
-(SFSDKAuthRequest *)migrateRefreshAuthRequest:(SFSDKAppConfig *)newAppConfig {
577+
SFSDKAuthRequest *request = [[SFSDKAuthRequest alloc] init];
578+
request.loginHost = self.loginHost;
579+
request.additionalOAuthParameterKeys = self.additionalOAuthParameterKeys;
580+
request.oauthClientId = newAppConfig.remoteAccessConsumerKey;
581+
request.oauthCompletionUrl = newAppConfig.oauthRedirectURI;
582+
request.scene = [[SFSDKWindowManager sharedManager] defaultScene];
583+
return request;
584+
}
585+
576586
-(SFSDKAuthRequest *)nativeLoginAuthRequest {
577587
SFNativeLoginManagerInternal *nativeLoginManager = (SFNativeLoginManagerInternal *)[[SalesforceSDKManager sharedManager] nativeLoginManager];
578588
SFSDKAuthRequest *request = [[SFSDKAuthRequest alloc] init];
@@ -815,6 +825,45 @@ - (void)dismissAuthViewControllerIfPresentForScene:(UIScene *)scene completion:(
815825
}
816826
}
817827

828+
- (void)migrateRefreshToken:(SFUserAccount *)user
829+
newAppConfig:(SFSDKAppConfig *)newAppConfig
830+
success:(SFUserAccountManagerSuccessCallbackBlock)completionBlock
831+
failure:(SFUserAccountManagerFailureCallbackBlock)failureBlock {
832+
833+
// Store current user credentials to revoke them once migration completes
834+
SFOAuthCredentials *preMigrationCredentials = self.currentUser.credentials;
835+
836+
// Creating a SFSDKAuthRequest and SFSDKAuthSession
837+
SFSDKAuthRequest *request = [self migrateRefreshAuthRequest:newAppConfig];
838+
SFSDKAuthSession *authSession = [[SFSDKAuthSession alloc] initWith:request credentials:nil];
839+
authSession.isAuthenticating = YES;
840+
authSession.authSuccessCallback = ^(SFOAuthInfo *authInfo, SFUserAccount *newUserAccount) {
841+
if (preMigrationCredentials != nil && ![preMigrationCredentials.refreshToken isEqualToString:newUserAccount.credentials.refreshToken]) {
842+
843+
id<SFSDKOAuthProtocol> authClient = self.authClient();
844+
[authClient revokeRefreshToken:preMigrationCredentials reason:SFLogoutReasonRefreshTokenRotated];
845+
}
846+
847+
if (completionBlock) {
848+
completionBlock(authInfo, newUserAccount);
849+
}
850+
};
851+
authSession.authFailureCallback = failureBlock;
852+
authSession.oauthCoordinator.delegate = self;
853+
authSession.identityCoordinator.delegate = self;
854+
self.authSessions[authSession.sceneId] = authSession;
855+
856+
// Kicking off the actual migration (will load front-door approval URL in web view)
857+
__weak typeof(self) weakSelf = self;
858+
dispatch_async(dispatch_get_main_queue(), ^{
859+
__strong typeof(weakSelf) strongSelf = weakSelf;
860+
[strongSelf dismissAuthViewControllerIfPresentForScene:authSession.oauthRequest.scene completion:^{
861+
[authSession.oauthCoordinator migrateRefreshToken:user];
862+
}];
863+
});
864+
}
865+
866+
818867
#pragma mark - SFOAuthCoordinatorDelegate
819868
- (void)oauthCoordinatorWillBeginAuthentication:(SFOAuthCoordinator *)coordinator authInfo:(SFOAuthInfo *)info {
820869
NSDictionary *userInfo = @{ kSFNotificationUserInfoCredentialsKey: coordinator.credentials,
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
/*
2+
Copyright (c) 2025-present, salesforce.com, inc. All rights reserved.
3+
4+
Redistribution and use of this software in source and binary forms, with or without modification,
5+
are permitted provided that the following conditions are met:
6+
* Redistributions of source code must retain the above copyright notice, this list of conditions
7+
and the following disclaimer.
8+
* Redistributions in binary form must reproduce the above copyright notice, this list of
9+
conditions and the following disclaimer in the documentation and/or other materials provided
10+
with the distribution.
11+
* Neither the name of salesforce.com, inc. nor the names of its contributors may be used to
12+
endorse or promote products derived from this software without specific prior written
13+
permission of salesforce.com, inc.
14+
15+
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR
16+
IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND
17+
FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR
18+
CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
19+
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
20+
DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY,
21+
WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY
22+
WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
23+
*/
24+
25+
#import <XCTest/XCTest.h>
26+
#import <SalesforceSDKCore/SalesforceSDKCore.h>
27+
#import "SFOAuthCoordinator+Internal.h"
28+
#import "SFUserAccount+Internal.h"
29+
#import "SFOAuthCredentials+Internal.h"
30+
#import "SFSDKAuthSession.h"
31+
#import "SFSDKAuthRequest.h"
32+
33+
@interface SFOAuthCoordinatorTests : XCTestCase
34+
35+
@end
36+
37+
@implementation SFOAuthCoordinatorTests
38+
39+
- (void)testMigrateRefreshTokenSetup {
40+
// Create test credentials
41+
SFOAuthCredentials *credentials = [[SFOAuthCredentials alloc] initWithIdentifier:@"testIdentifier" clientId:@"testClientId" encrypted:NO];
42+
credentials.redirectUri = @"testapp://callback";
43+
credentials.domain = @"test.salesforce.com";
44+
credentials.accessToken = @"testAccessToken";
45+
credentials.refreshToken = @"testRefreshToken";
46+
credentials.instanceUrl = [NSURL URLWithString:@"https://test.salesforce.com"];
47+
48+
// Create a test user account (not fully logged in to avoid actual API calls)
49+
SFUserAccount *userAccount = [[SFUserAccount alloc] initWithCredentials:credentials];
50+
51+
// Create auth request and session
52+
SFSDKAuthRequest *authRequest = [[SFSDKAuthRequest alloc] init];
53+
authRequest.oauthClientId = @"newClientId";
54+
authRequest.oauthCompletionUrl = @"newapp://callback";
55+
authRequest.loginHost = @"login.salesforce.com";
56+
57+
SFSDKAuthSession *authSession = [[SFSDKAuthSession alloc] initWith:authRequest credentials:nil];
58+
59+
// Track whether callbacks are invoked
60+
__block BOOL failureCallbackInvoked = NO;
61+
__block SFOAuthInfo *capturedAuthInfo = nil;
62+
__block NSError *capturedError = nil;
63+
64+
authSession.authFailureCallback = ^(SFOAuthInfo *authInfo, NSError *error) {
65+
failureCallbackInvoked = YES;
66+
capturedAuthInfo = authInfo;
67+
capturedError = error;
68+
};
69+
70+
// Create coordinator
71+
SFOAuthCoordinator *coordinator = [[SFOAuthCoordinator alloc] initWithAuthSession:authSession];
72+
coordinator.credentials = credentials;
73+
74+
// Verify initial state
75+
XCTAssertNotNil(coordinator.credentials);
76+
XCTAssertEqualObjects(coordinator.credentials.clientId, @"testClientId");
77+
78+
// Call migrateRefreshToken - this will attempt to make a REST API call
79+
// which will fail because the user is not properly logged in
80+
[coordinator migrateRefreshToken:userAccount];
81+
82+
// Wait a bit for the async failure callback
83+
XCTestExpectation *expectation = [self expectationWithDescription:@"Wait for failure callback"];
84+
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(1.0 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
85+
[expectation fulfill];
86+
});
87+
[self waitForExpectations:@[expectation] timeout:2.0];
88+
89+
// Verify that the auth info was set to the correct type
90+
// This happens synchronously before the REST call
91+
XCTAssertNotNil(coordinator.authInfo, @"Auth info should be set");
92+
XCTAssertEqual(coordinator.authInfo.authType, SFOAuthTypeRefreshTokenMigration, @"Auth type should be refresh token migration");
93+
94+
// Verify initialRequestLoaded was set to false
95+
XCTAssertFalse(coordinator.initialRequestLoaded, @"Initial request loaded should be false");
96+
97+
// Verify the failure callback was invoked (because the user isn't logged in properly)
98+
XCTAssertTrue(failureCallbackInvoked, @"Failure callback should be invoked when REST API fails");
99+
XCTAssertNotNil(capturedError, @"Should have captured an error");
100+
XCTAssertEqual(capturedAuthInfo.authType, SFOAuthTypeRefreshTokenMigration, @"AuthInfo type should be refresh token migration");
101+
}
102+
103+
@end
104+

0 commit comments

Comments
 (0)