From dfcf6b9d3793aed7f264c8e106e372b6b45eab11 Mon Sep 17 00:00:00 2001 From: Brandon Page Date: Fri, 24 Apr 2026 11:16:36 -0700 Subject: [PATCH 1/3] Merge pull request #4022 from brandonpage/login-for-admin (#4024) Add Login for Admin feature. --- .../project.pbxproj | 4 + .../Classes/Login/SFLoginViewController.h | 8 + .../Classes/Login/SFLoginViewController.m | 12 +- .../Classes/OAuth/SFOAuthCoordinator.m | 6 +- .../Classes/OAuth/SFSDKAuthRequest.h | 5 + .../Classes/OAuth/SFSDKAuthSession.m | 2 +- .../UserAccount/SFUserAccountManager.m | 19 +- .../LoginForAdminTests.swift | 510 ++++++++++++++++++ .../SalesforceSDKCoreTests-Bridging-Header.h | 6 + .../PageObjects/LoginPageObject.swift | 24 + .../Tests/LoginForAdminTests.swift | 67 +++ .../Util/BaseAuthFlowTester.swift | 26 +- .../en.lproj/Localizable.strings | 1 + 13 files changed, 677 insertions(+), 13 deletions(-) create mode 100644 libs/SalesforceSDKCore/SalesforceSDKCoreTests/LoginForAdminTests.swift create mode 100644 native/SampleApps/AuthFlowTester/AuthFlowTesterUITests/Tests/LoginForAdminTests.swift diff --git a/libs/SalesforceSDKCore/SalesforceSDKCore.xcodeproj/project.pbxproj b/libs/SalesforceSDKCore/SalesforceSDKCore.xcodeproj/project.pbxproj index bba012f897..13ac408f67 100644 --- a/libs/SalesforceSDKCore/SalesforceSDKCore.xcodeproj/project.pbxproj +++ b/libs/SalesforceSDKCore/SalesforceSDKCore.xcodeproj/project.pbxproj @@ -79,6 +79,7 @@ 4F3139682331C5C7007B3705 /* SFSDKAuthRootController.h in Headers */ = {isa = PBXBuildFile; fileRef = 4F3139672331C5B9007B3705 /* SFSDKAuthRootController.h */; }; 4F3ECD8A2EBBD150005020A6 /* SFOAuthCoordinatorTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 4F3ECD892EBBD150005020A6 /* SFOAuthCoordinatorTests.m */; }; 4F3ECD8C2EBBD182005020A6 /* SFOAuthInfoTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 4F3ECD8B2EBBD182005020A6 /* SFOAuthInfoTests.m */; }; + 4FA1B2C32F0E000000000001 /* LoginForAdminTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FA1B2C32F0E000000000002 /* LoginForAdminTests.m */; }; 4F5727E327F27F1A0008CDA4 /* SFSDKPrimingRecordsResponse.h in Headers */ = {isa = PBXBuildFile; fileRef = 4F5727DC27F27F1A0008CDA4 /* SFSDKPrimingRecordsResponse.h */; settings = {ATTRIBUTES = (Public, ); }; }; 4F5727E427F27F1A0008CDA4 /* SFSDKPrimingRecordsResponse.m in Sources */ = {isa = PBXBuildFile; fileRef = 4F5727E227F27F1A0008CDA4 /* SFSDKPrimingRecordsResponse.m */; }; 4F5A49502E98711600C89DDD /* ScopeParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F5A494F2E98711600C89DDD /* ScopeParser.swift */; }; @@ -605,6 +606,7 @@ 4F3139672331C5B9007B3705 /* SFSDKAuthRootController.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = SFSDKAuthRootController.h; sourceTree = ""; }; 4F3ECD892EBBD150005020A6 /* SFOAuthCoordinatorTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = SFOAuthCoordinatorTests.m; sourceTree = ""; }; 4F3ECD8B2EBBD182005020A6 /* SFOAuthInfoTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = SFOAuthInfoTests.m; sourceTree = ""; }; + 4FA1B2C32F0E000000000002 /* LoginForAdminTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginForAdminTests.swift; sourceTree = ""; }; 4F5727DC27F27F1A0008CDA4 /* SFSDKPrimingRecordsResponse.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = SFSDKPrimingRecordsResponse.h; sourceTree = ""; }; 4F5727E227F27F1A0008CDA4 /* SFSDKPrimingRecordsResponse.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = SFSDKPrimingRecordsResponse.m; sourceTree = ""; }; 4F5A494F2E98711600C89DDD /* ScopeParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScopeParser.swift; sourceTree = ""; }; @@ -1100,6 +1102,7 @@ 4F7EB3F81BFFC87600768720 /* SFEncryptionKeyTests.m */, B7352CA422761D8400DA2CFF /* SFManagedPreferencesTest.m */, 69CEBC7D22F368CF00F16218 /* SFNetworkTests.m */, + 4FA1B2C32F0E000000000002 /* LoginForAdminTests.swift */, 4F3ECD892EBBD150005020A6 /* SFOAuthCoordinatorTests.m */, 23EED8892E2ACD3300646B10 /* SFOAuthCoordinatorTests.swift */, 4F9E05332DD7BE0A00548985 /* SFOAuthCredentialsTests.m */, @@ -2295,6 +2298,7 @@ 4F7EB41B1BFFC8D700768720 /* SFSDKCryptoUtilsTests.m in Sources */, 69848CB82364035300893E57 /* SFSDKEncryptedPushNotificationTests.m in Sources */, 4F3ECD8A2EBBD150005020A6 /* SFOAuthCoordinatorTests.m in Sources */, + 4FA1B2C32F0E000000000001 /* LoginForAdminTests.swift in Sources */, 4F9E05322DD6A08000548985 /* SFSDKOAuthTokenEndpointResponseTests.m in Sources */, 4F06AF8D1C49A18E00F70798 /* SalesforceSDKManagerTests.m in Sources */, 237C186C2E44FCAE0008015C /* EncryptStreamTests.swift in Sources */, diff --git a/libs/SalesforceSDKCore/SalesforceSDKCore/Classes/Login/SFLoginViewController.h b/libs/SalesforceSDKCore/SalesforceSDKCore/Classes/Login/SFLoginViewController.h index 4860f8a25e..078d002ac0 100644 --- a/libs/SalesforceSDKCore/SalesforceSDKCore/Classes/Login/SFLoginViewController.h +++ b/libs/SalesforceSDKCore/SalesforceSDKCore/Classes/Login/SFLoginViewController.h @@ -57,6 +57,14 @@ NS_SWIFT_NAME(SalesforceLoginViewControllerDelegate) - (void)loginViewControllerDidChangeLoginOptions:(nonnull SFLoginViewController *)loginViewController; +/** + * Notifies the delegate that the user selected "Login for Admin" from the settings menu. + * This forces browser-based (advanced) authentication via ASWebAuthenticationSession, + * regardless of org configuration, to support phishing-resistant MFA. + * @param loginViewController The instance sending this message. + */ +- (void)loginViewControllerDidSelectLoginForAdmin:(nonnull SFLoginViewController *)loginViewController; + @end /** The Salesforce login screen view. diff --git a/libs/SalesforceSDKCore/SalesforceSDKCore/Classes/Login/SFLoginViewController.m b/libs/SalesforceSDKCore/SalesforceSDKCore/Classes/Login/SFLoginViewController.m index 21f08d4140..774b9810ae 100644 --- a/libs/SalesforceSDKCore/SalesforceSDKCore/Classes/Login/SFLoginViewController.m +++ b/libs/SalesforceSDKCore/SalesforceSDKCore/Classes/Login/SFLoginViewController.m @@ -325,7 +325,17 @@ - (UIBarButtonItem *)createSettingsButton { [self presentViewController:configPicker animated:YES completion:nil]; }]]; } - + + // Login for Admin - forces browser-based (advanced) authentication to support phishing-resistant MFA. + [menuActions addObject:[UIAction actionWithTitle:[SFSDKResourceUtils localizedString:@"LOGIN_FOR_ADMIN"] + image:nil + identifier:nil + handler:^(__kindof UIAction* _Nonnull action) { + if ([self.delegate respondsToSelector:@selector(loginViewControllerDidSelectLoginForAdmin:)]) { + [self.delegate loginViewControllerDidSelectLoginForAdmin:self]; + } + }]]; + UIMenu *menu = [UIMenu menuWithTitle:@"" // No title children:menuActions]; UIBarButtonItem *settingsButton = [[UIBarButtonItem alloc] initWithImage:image menu:menu]; diff --git a/libs/SalesforceSDKCore/SalesforceSDKCore/Classes/OAuth/SFOAuthCoordinator.m b/libs/SalesforceSDKCore/SalesforceSDKCore/Classes/OAuth/SFOAuthCoordinator.m index 9019222aa7..9e3f536f8d 100644 --- a/libs/SalesforceSDKCore/SalesforceSDKCore/Classes/OAuth/SFOAuthCoordinator.m +++ b/libs/SalesforceSDKCore/SalesforceSDKCore/Classes/OAuth/SFOAuthCoordinator.m @@ -145,6 +145,8 @@ - (void)authenticate { self.authenticating = YES; if (self.credentials.refreshToken) { self.authInfo = [[SFOAuthInfo alloc] initWithAuthType:SFOAuthTypeRefresh]; + } else if (self.useBrowserAuth) { + self.authInfo = [[SFOAuthInfo alloc] initWithAuthType:SFOAuthTypeAdvancedBrowser]; } else if ([[SalesforceSDKManager sharedManager] useWebServerAuthentication]) { self.authInfo = [[SFOAuthInfo alloc] initWithAuthType:SFOAuthTypeWebServer]; } else { @@ -777,7 +779,7 @@ - (void)handleUserAgentResponse:(NSURL *)requestUrl { - (NSString *)generateApprovalUrlString { return [self approvalURLForEndpoint:[self brandedAuthorizeURL] credentials:self.credentials - webServerFlow:[[SalesforceSDKManager sharedManager] useWebServerAuthentication] + webServerFlow:(self.useBrowserAuth || [[SalesforceSDKManager sharedManager] useWebServerAuthentication]) protocol:nil domain:nil codeChallenge:nil]; @@ -893,7 +895,7 @@ - (void)webView:(WKWebView *)webView decidePolicyForNavigationAction:(WKNavigati // If a front door bridge URL override is present, use its code verifier to choose between user agent or web server authentication. if (self.frontdoorBridgeLoginOverride.frontdoorBridgeUrl // Check if an override is provided ? self.frontdoorBridgeLoginOverride.codeVerifier != nil // If yes, only proceed if it's a web server flow as indicated by a code verifier. - : [[SalesforceSDKManager sharedManager] useWebServerAuthentication] // If there's no override use the default SDK setting. + : (self.useBrowserAuth || [[SalesforceSDKManager sharedManager] useWebServerAuthentication]) // If there's no override use browser auth or the default SDK setting. ) { [self handleWebServerResponse:url]; // Web server flow/URLs with query string parameters. diff --git a/libs/SalesforceSDKCore/SalesforceSDKCore/Classes/OAuth/SFSDKAuthRequest.h b/libs/SalesforceSDKCore/SalesforceSDKCore/Classes/OAuth/SFSDKAuthRequest.h index e886007652..dfc25308e5 100644 --- a/libs/SalesforceSDKCore/SalesforceSDKCore/Classes/OAuth/SFSDKAuthRequest.h +++ b/libs/SalesforceSDKCore/SalesforceSDKCore/Classes/OAuth/SFSDKAuthRequest.h @@ -32,6 +32,11 @@ NS_ASSUME_NONNULL_BEGIN @interface SFSDKAuthRequest : NSObject @property (nonatomic, assign) BOOL useBrowserAuth; + +/// Indicates that browser auth was initiated by the "Login for Admin" action. +/// When YES, cancelling the browser session returns to the WebView login instead of showing the server picker. +@property (nonatomic, assign) BOOL loginAsAdmin; + @property (nonatomic, strong) NSArray *additionalOAuthParameterKeys; @property (nonatomic, strong) NSDictionary * additionalTokenRefreshParams; @property (nonatomic, copy) NSString *loginHost; diff --git a/libs/SalesforceSDKCore/SalesforceSDKCore/Classes/OAuth/SFSDKAuthSession.m b/libs/SalesforceSDKCore/SalesforceSDKCore/Classes/OAuth/SFSDKAuthSession.m index 9d7ab24b20..7d07f9e74f 100644 --- a/libs/SalesforceSDKCore/SalesforceSDKCore/Classes/OAuth/SFSDKAuthSession.m +++ b/libs/SalesforceSDKCore/SalesforceSDKCore/Classes/OAuth/SFSDKAuthSession.m @@ -60,7 +60,7 @@ -(void)initCoordinator { self.oauthCoordinator.additionalTokenRefreshParams = self.oauthRequest.additionalTokenRefreshParams; self.oauthCoordinator.scopes = self.oauthRequest.scopes; self.oauthCoordinator.brandLoginPath = self.oauthRequest.brandLoginPath; - self.oauthCoordinator.useBrowserAuth = self.oauthRequest.useBrowserAuth; + self.oauthCoordinator.useBrowserAuth = self.oauthRequest.useBrowserAuth || self.oauthRequest.loginAsAdmin; // TODO: Remove in Mobile SDK 14.0 #pragma clang diagnostic push diff --git a/libs/SalesforceSDKCore/SalesforceSDKCore/Classes/UserAccount/SFUserAccountManager.m b/libs/SalesforceSDKCore/SalesforceSDKCore/Classes/UserAccount/SFUserAccountManager.m index b27048c17c..885dce308a 100644 --- a/libs/SalesforceSDKCore/SalesforceSDKCore/Classes/UserAccount/SFUserAccountManager.m +++ b/libs/SalesforceSDKCore/SalesforceSDKCore/Classes/UserAccount/SFUserAccountManager.m @@ -1030,14 +1030,22 @@ - (void)oauthCoordinatorDidCancelBrowserAuthentication:(SFOAuthCoordinator *)coo }); return; } - + + // When "Login for Admin" initiated the browser auth, clear the flag and + // restart the WebView login flow instead of showing the server picker. + if (coordinator.authSession.oauthRequest.loginAsAdmin) { + coordinator.authSession.oauthRequest.loginAsAdmin = NO; + [self restartAuthentication:coordinator.authSession]; + return; + } + if (self.nativeLoginEnabled && self.shouldFallbackToWebAuthentication) { self.shouldFallbackToWebAuthentication = NO; [self stopCurrentAuthentication:nil]; [self loginWithCompletion:^(SFOAuthInfo* authInfo, SFUserAccount* user) { } failure:^(SFOAuthInfo* authInfo, NSError* error) { }]; return; } - + SFOAuthInfo *authInfo = [[SFOAuthInfo alloc] initWithAuthType:SFOAuthTypeAdvancedBrowser]; NSDictionary *userInfo = @{ kSFNotificationUserInfoCredentialsKey: coordinator.credentials, kSFNotificationUserInfoAuthTypeKey: authInfo }; @@ -1114,6 +1122,13 @@ - (void)loginViewControllerDidReload:(SFLoginViewController *)loginViewControlle [self restartAuthenticationForViewController:loginViewController]; } +- (void)loginViewControllerDidSelectLoginForAdmin:(SFLoginViewController *)loginViewController { + NSString *sceneId = loginViewController.view.window.windowScene.session.persistentIdentifier; + SFSDKAuthSession *session = self.authSessions[sceneId]; + session.oauthRequest.loginAsAdmin = YES; + [self restartAuthenticationForViewController:loginViewController]; +} + - (void)loginViewControllerDidChangeLoginOptions:(SFLoginViewController *)loginViewController { [self restartAuthenticationForViewController:loginViewController recreateAuthRequest:YES]; } diff --git a/libs/SalesforceSDKCore/SalesforceSDKCoreTests/LoginForAdminTests.swift b/libs/SalesforceSDKCore/SalesforceSDKCoreTests/LoginForAdminTests.swift new file mode 100644 index 0000000000..5459f22dbd --- /dev/null +++ b/libs/SalesforceSDKCore/SalesforceSDKCoreTests/LoginForAdminTests.swift @@ -0,0 +1,510 @@ +/* + Copyright (c) 2026-present, salesforce.com, inc. All rights reserved. + + Redistribution and use of this software in source and binary forms, with or without modification, + are permitted provided that the following conditions are met: + * Redistributions of source code must retain the above copyright notice, this list of conditions + and the following disclaimer. + * Redistributions in binary form must reproduce the above copyright notice, this list of + conditions and the following disclaimer in the documentation and/or other materials provided + with the distribution. + * Neither the name of salesforce.com, inc. nor the names of its contributors may be used to + endorse or promote products derived from this software without specific prior written + permission of salesforce.com, inc. + + THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR + IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND + FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR + CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, + WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY + WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +import XCTest +@testable import SalesforceSDKCore + +// MARK: - Login for Admin Tests + +class LoginForAdminTests: XCTestCase { + + private var originalUseWebServerAuth: Bool = true + private var originalUseHybridAuth: Bool = false + + override func setUp() { + super.setUp() + originalUseWebServerAuth = SalesforceManager.shared.useWebServerAuthentication + originalUseHybridAuth = SalesforceManager.shared.useHybridAuthentication + } + + override func tearDown() { + SalesforceManager.shared.useWebServerAuthentication = originalUseWebServerAuth + SalesforceManager.shared.useHybridAuthentication = originalUseHybridAuth + super.tearDown() + } + + // MARK: - SFSDKAuthRequest loginAsAdmin Property + + func testGivenNewAuthRequest_whenCreated_thenLoginAsAdminIsFalse() { + let request = SFSDKAuthRequest() + XCTAssertFalse(request.loginAsAdmin, "loginAsAdmin should default to false") + } + + func testGivenAuthRequest_whenLoginAsAdminSet_thenUseBrowserAuthUnchanged() { + let request = SFSDKAuthRequest() + XCTAssertFalse(request.useBrowserAuth, "useBrowserAuth should default to false") + + request.loginAsAdmin = true + + XCTAssertTrue(request.loginAsAdmin, "loginAsAdmin should be true after setting") + XCTAssertFalse(request.useBrowserAuth, "useBrowserAuth should remain false when loginAsAdmin is set") + } + + // MARK: - SFSDKAuthSession Coordinator Initialization + + func testGivenLoginAsAdmin_whenAuthSessionCreated_thenCoordinatorUsesBrowserAuth() { + let request = makeAuthRequest() + request.loginAsAdmin = true + request.useBrowserAuth = false + + let session = SFSDKAuthSession(request, credentials: nil) + XCTAssertTrue(session.oauthCoordinator.useBrowserAuth, + "Coordinator useBrowserAuth should be true when loginAsAdmin is true") + } + + func testGivenUseBrowserAuthOnly_whenAuthSessionCreated_thenCoordinatorUsesBrowserAuth() { + let request = makeAuthRequest() + request.loginAsAdmin = false + request.useBrowserAuth = true + + let session = SFSDKAuthSession(request, credentials: nil) + XCTAssertTrue(session.oauthCoordinator.useBrowserAuth, + "Coordinator useBrowserAuth should be true when request useBrowserAuth is true") + } + + func testGivenNeitherFlag_whenAuthSessionCreated_thenCoordinatorDoesNotUseBrowserAuth() { + let request = makeAuthRequest() + request.loginAsAdmin = false + request.useBrowserAuth = false + + let session = SFSDKAuthSession(request, credentials: nil) + XCTAssertFalse(session.oauthCoordinator.useBrowserAuth, + "Coordinator useBrowserAuth should be false when both flags are false") + } + + // MARK: - SFOAuthCoordinator Auth Info Type + + func testGivenLoginAsAdmin_whenAuthenticate_thenAuthInfoIsAdvancedBrowser() { + createTestAppIdentity() + + let request = makeAuthRequest() + request.loginAsAdmin = true + + let session = SFSDKAuthSession(request, credentials: nil) + session.oauthCoordinator.delegate = self + + session.oauthCoordinator.authenticate() + + XCTAssertEqual(session.oauthCoordinator.authInfo.authType, AuthInfo.AuthType.advancedBrowser, + "Auth info type should be advancedBrowser when loginAsAdmin is true") + session.oauthCoordinator.stopAuthentication() + } + + func testGivenWebServerAuth_whenAuthenticate_thenAuthInfoIsWebServer() { + createTestAppIdentity() + SalesforceManager.shared.useWebServerAuthentication = true + + let request = makeAuthRequest() + request.loginAsAdmin = false + request.useBrowserAuth = false + + let session = SFSDKAuthSession(request, credentials: nil) + session.oauthCoordinator.delegate = self + + session.oauthCoordinator.authenticate() + + XCTAssertEqual(session.oauthCoordinator.authInfo.authType, AuthInfo.AuthType.webServer, + "Auth info type should be webServer when useWebServerAuthentication is true") + session.oauthCoordinator.stopAuthentication() + } + + func testGivenUserAgentAuth_whenAuthenticate_thenAuthInfoIsUserAgent() { + createTestAppIdentity() + SalesforceManager.shared.useWebServerAuthentication = false + + let request = makeAuthRequest() + request.loginAsAdmin = false + request.useBrowserAuth = false + + let session = SFSDKAuthSession(request, credentials: nil) + session.oauthCoordinator.delegate = self + + session.oauthCoordinator.authenticate() + + XCTAssertEqual(session.oauthCoordinator.authInfo.authType, AuthInfo.AuthType.userAgent, + "Auth info type should be userAgent when both flags are false") + session.oauthCoordinator.stopAuthentication() + } + + // MARK: - Approval URL Web Server Flow + + func testGivenLoginAsAdmin_whenGenerateApprovalUrl_thenUsesWebServerFlow() { + createTestAppIdentity() + SalesforceManager.shared.useWebServerAuthentication = false + + let request = makeAuthRequest() + request.loginAsAdmin = true + + let session = SFSDKAuthSession(request, credentials: nil) + let approvalUrl = session.oauthCoordinator.generateApprovalUrlString() + + XCTAssertTrue(approvalUrl.contains("response_type=code"), + "Approval URL should use response_type=code when loginAsAdmin is true") + XCTAssertFalse(approvalUrl.contains("response_type=token"), + "Approval URL should not use response_type=token when loginAsAdmin is true") + } + + func testGivenNoLoginAsAdmin_whenWebServerAuthDisabled_thenUsesUserAgentFlow() { + createTestAppIdentity() + SalesforceManager.shared.useWebServerAuthentication = false + SalesforceManager.shared.useHybridAuthentication = false + + let request = makeAuthRequest() + request.loginAsAdmin = false + request.useBrowserAuth = false + + let session = SFSDKAuthSession(request, credentials: nil) + let approvalUrl = session.oauthCoordinator.generateApprovalUrlString() + + XCTAssertTrue(approvalUrl.contains("response_type=token"), + "Approval URL should use response_type=token when loginAsAdmin is false and web server auth is disabled") + } + + // MARK: - No Global State Mutation + + func testGivenLoginAsAdmin_whenSet_thenGlobalWebServerAuthUnchanged() { + let originalValue = SalesforceManager.shared.useWebServerAuthentication + + let request = SFSDKAuthRequest() + request.loginAsAdmin = true + + XCTAssertEqual(SalesforceManager.shared.useWebServerAuthentication, originalValue, + "Setting loginAsAdmin should not change the global useWebServerAuthentication") + } + + func testGivenLoginAsAdmin_whenAuthSessionCreated_thenGlobalStateUnchanged() { + SalesforceManager.shared.useWebServerAuthentication = false + + let request = makeAuthRequest() + request.loginAsAdmin = true + + let session = SFSDKAuthSession(request, credentials: nil) + + XCTAssertTrue(session.oauthCoordinator.useBrowserAuth, + "Coordinator should use browser auth") + XCTAssertFalse(SalesforceManager.shared.useWebServerAuthentication, + "Global useWebServerAuthentication should remain false") + } + + // MARK: - Cancel Flow: loginAsAdmin Clears on Cancel + + func testGivenLoginAsAdmin_whenCancelled_thenLoginAsAdminCleared() { + let request = makeAuthRequest() + request.loginAsAdmin = true + + XCTAssertTrue(request.loginAsAdmin, "loginAsAdmin should be true before cancel") + + // Simulate what the cancel handler does + request.loginAsAdmin = false + + XCTAssertFalse(request.loginAsAdmin, "loginAsAdmin should be false after cancel") + + // Creating a new session from the cleared request should not use browser auth + let session = SFSDKAuthSession(request, credentials: nil) + XCTAssertFalse(session.oauthCoordinator.useBrowserAuth, + "Coordinator should not use browser auth after loginAsAdmin is cleared") + } + + func testGivenLoginAsAdminCancelled_whenNewSession_thenAuthInfoMatchesGlobalSetting() { + createTestAppIdentity() + SalesforceManager.shared.useWebServerAuthentication = true + + let request = makeAuthRequest() + request.loginAsAdmin = true + + // Admin session uses advanced browser + let adminSession = SFSDKAuthSession(request, credentials: nil) + adminSession.oauthCoordinator.delegate = self + adminSession.oauthCoordinator.authenticate() + XCTAssertEqual(adminSession.oauthCoordinator.authInfo.authType, AuthInfo.AuthType.advancedBrowser) + adminSession.oauthCoordinator.stopAuthentication() + + // Simulate cancel + request.loginAsAdmin = false + + // New session from same request uses global setting + let normalSession = SFSDKAuthSession(request, credentials: nil) + normalSession.oauthCoordinator.delegate = self + normalSession.oauthCoordinator.authenticate() + XCTAssertEqual(normalSession.oauthCoordinator.authInfo.authType, AuthInfo.AuthType.webServer, + "After cancel, auth type should match global setting (webServer)") + normalSession.oauthCoordinator.stopAuthentication() + } + + // MARK: - Cancel Flow: Org-Initiated Browser Auth Unchanged + + func testGivenOrgInitiatedBrowserAuth_whenCancelled_thenUseBrowserAuthPreserved() { + let request = makeAuthRequest() + request.useBrowserAuth = true + request.loginAsAdmin = false + + XCTAssertFalse(request.loginAsAdmin, "loginAsAdmin should be false for org-initiated browser auth") + XCTAssertTrue(request.useBrowserAuth, "useBrowserAuth should be true for org-initiated browser auth") + + // After cancel of org-initiated auth, useBrowserAuth should remain true + let session = SFSDKAuthSession(request, credentials: nil) + XCTAssertTrue(session.oauthCoordinator.useBrowserAuth, + "Org-initiated browser auth should keep useBrowserAuth after cancel") + } + + // MARK: - useBrowserAuth Not Modified + + func testGivenLoginAsAdmin_whenFullLifecycle_thenUseBrowserAuthNeverMutated() { + let request = makeAuthRequest() + request.useBrowserAuth = false + + // Set loginAsAdmin + request.loginAsAdmin = true + XCTAssertFalse(request.useBrowserAuth, "useBrowserAuth should remain false after setting loginAsAdmin") + + // Create session - coordinator gets useBrowserAuth from the OR of both flags + let session = SFSDKAuthSession(request, credentials: nil) + XCTAssertTrue(session.oauthCoordinator.useBrowserAuth, "Coordinator should use browser auth") + XCTAssertFalse(request.useBrowserAuth, "Request useBrowserAuth should still be false") + + // Clear loginAsAdmin (cancel) + request.loginAsAdmin = false + XCTAssertFalse(request.useBrowserAuth, "useBrowserAuth should still be false after clearing loginAsAdmin") + + // New session should not use browser auth + let newSession = SFSDKAuthSession(request, credentials: nil) + XCTAssertFalse(newSession.oauthCoordinator.useBrowserAuth, "New coordinator should not use browser auth") + } + + // MARK: - SFLoginViewControllerDelegate Declaration + + func testGivenSFLoginViewControllerDelegate_thenLoginForAdminMethodExists() { + let manager = UserAccountManager.shared + XCTAssertTrue(manager.responds(to: NSSelectorFromString("loginViewControllerDidSelectLoginForAdmin:")), + "SFUserAccountManager should respond to loginViewControllerDidSelectLoginForAdmin:") + } + + // MARK: - SFUserAccountManager Cancel Browser Auth (loginAsAdmin path) + + func testGivenLoginAsAdmin_whenBrowserAuthCancelled_thenLoginAsAdminCleared() { + let request = makeAuthRequest() + request.loginAsAdmin = true + + let session = SFSDKAuthSession(request, credentials: nil) + session.oauthCoordinator.delegate = UserAccountManager.shared + + XCTAssertTrue(session.oauthRequest.loginAsAdmin, "loginAsAdmin should be true before cancel") + + // Call the cancel handler on the main thread (it dispatches to main if not already there) + UserAccountManager.shared.oauthCoordinatorDidCancelBrowserAuthentication(session.oauthCoordinator) + + XCTAssertFalse(session.oauthRequest.loginAsAdmin, "loginAsAdmin should be cleared after cancel") + } + + func testGivenLoginAsAdmin_whenBrowserAuthCancelled_thenUserCancelledNotificationNotPosted() { + let request = makeAuthRequest() + request.loginAsAdmin = true + + let session = SFSDKAuthSession(request, credentials: nil) + session.oauthCoordinator.delegate = UserAccountManager.shared + + var notificationPosted = false + let observer = NotificationCenter.default.addObserver( + forName: UserAccountManager.userCancelledAuthentication, + object: nil, queue: nil + ) { _ in + notificationPosted = true + } + + UserAccountManager.shared.oauthCoordinatorDidCancelBrowserAuthentication(session.oauthCoordinator) + + XCTAssertFalse(notificationPosted, + "kSFNotificationUserCancelledAuth should NOT be posted when loginAsAdmin cancel restarts auth") + + NotificationCenter.default.removeObserver(observer) + } + + func testGivenNoLoginAsAdmin_whenBrowserAuthCancelled_thenUserCancelledNotificationPosted() { + let request = makeAuthRequest() + request.loginAsAdmin = false + request.useBrowserAuth = false + + let session = SFSDKAuthSession(request, credentials: nil) + session.oauthCoordinator.delegate = UserAccountManager.shared + + // Set the handler block to avoid the server picker UI code path + var handlerCalled = false + UserAccountManager.shared.authCancelledByUserHandlerBlock = { + handlerCalled = true + } + + var notificationPosted = false + let observer = NotificationCenter.default.addObserver( + forName: UserAccountManager.userCancelledAuthentication, + object: nil, queue: nil + ) { _ in + notificationPosted = true + } + + UserAccountManager.shared.oauthCoordinatorDidCancelBrowserAuthentication(session.oauthCoordinator) + + XCTAssertTrue(notificationPosted, + "kSFNotificationUserCancelledAuth should be posted when loginAsAdmin is false") + XCTAssertTrue(handlerCalled, + "authCancelledByUserHandlerBlock should be called when loginAsAdmin is false") + + NotificationCenter.default.removeObserver(observer) + UserAccountManager.shared.authCancelledByUserHandlerBlock = nil + } + + func testGivenLoginAsAdmin_whenBrowserAuthCancelled_thenHandlerBlockNotCalled() { + let request = makeAuthRequest() + request.loginAsAdmin = true + + let session = SFSDKAuthSession(request, credentials: nil) + session.oauthCoordinator.delegate = UserAccountManager.shared + + var handlerCalled = false + UserAccountManager.shared.authCancelledByUserHandlerBlock = { + handlerCalled = true + } + + UserAccountManager.shared.oauthCoordinatorDidCancelBrowserAuthentication(session.oauthCoordinator) + + XCTAssertFalse(handlerCalled, + "authCancelledByUserHandlerBlock should NOT be called when loginAsAdmin cancel restarts auth") + + UserAccountManager.shared.authCancelledByUserHandlerBlock = nil + } + + // MARK: - SFUserAccountManager Cancel Browser Auth (nativeLogin fallback path) + + func testGivenNativeLoginFallback_whenBrowserAuthCancelled_thenFallbackConsumedAndRestartsNativeLogin() { + let request = makeAuthRequest() + request.loginAsAdmin = false + + let session = SFSDKAuthSession(request, credentials: nil) + session.oauthCoordinator.delegate = UserAccountManager.shared + + let uam = UserAccountManager.shared + let originalNativeLoginEnabled = uam.nativeLoginEnabled + let originalShouldFallback = uam.shouldFallbackToWebAuthentication + + // Simulate: native login set shouldFallbackToWebAuthentication = YES, + // which caused browser auth. The user is now cancelling that browser session. + uam.nativeLoginEnabled = true + uam.shouldFallbackToWebAuthentication = true + + var notificationPosted = false + let observer = NotificationCenter.default.addObserver( + forName: UserAccountManager.userCancelledAuthentication, + object: nil, queue: nil + ) { _ in + notificationPosted = true + } + + var handlerCalled = false + uam.authCancelledByUserHandlerBlock = { + handlerCalled = true + } + + uam.oauthCoordinatorDidCancelBrowserAuthentication(session.oauthCoordinator) + + // The fallback flag is consumed (set to NO) so the next loginWithCompletion: + // call returns to native login instead of launching another browser session. + XCTAssertFalse(uam.shouldFallbackToWebAuthentication, + "shouldFallbackToWebAuthentication should be consumed so next login attempt uses native login") + // This path returns early — no cancelled notification and no handler block call. + XCTAssertFalse(notificationPosted, + "kSFNotificationUserCancelledAuth should NOT be posted for native login fallback path") + XCTAssertFalse(handlerCalled, + "authCancelledByUserHandlerBlock should NOT be called for native login fallback path") + + NotificationCenter.default.removeObserver(observer) + uam.authCancelledByUserHandlerBlock = nil + uam.nativeLoginEnabled = originalNativeLoginEnabled + uam.shouldFallbackToWebAuthentication = originalShouldFallback + } + + // MARK: - SFUserAccountManager loginViewControllerDidSelectLoginForAdmin + + func testGivenAuthSession_whenLoginForAdminSelected_thenLoginAsAdminSetAndAuthRestarted() { + let uam = UserAccountManager.shared + + // Get the test app's active window scene to obtain a real sceneId + guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene else { + XCTFail("Test requires a UIWindowScene from the running test app") + return + } + let sceneId = windowScene.session.persistentIdentifier + + // Create an auth session and seed it into authSessions with the real sceneId + let request = makeAuthRequest() + request.loginAsAdmin = false + let session = SFSDKAuthSession(request, credentials: nil) + uam.authSessions[sceneId as NSString] = session + + XCTAssertFalse(session.oauthRequest.loginAsAdmin, "loginAsAdmin should be false before selecting Login for Admin") + + // Create a SalesforceLoginViewController and place it in the window so its + // view.window.windowScene resolves to the same scene + let loginVC = SalesforceLoginViewController() + let window = windowScene.windows.first ?? UIWindow(windowScene: windowScene) + window.rootViewController = loginVC + window.makeKeyAndVisible() + loginVC.loadViewIfNeeded() + + // Call the delegate method via performSelector since the protocol conformance is internal + let selector = NSSelectorFromString("loginViewControllerDidSelectLoginForAdmin:") + uam.perform(selector, with: loginVC) + + XCTAssertTrue(session.oauthRequest.loginAsAdmin, + "loginAsAdmin should be true after loginViewControllerDidSelectLoginForAdmin:") + + // Clean up + uam.authSessions.removeObject(sceneId as NSString) + window.rootViewController = nil + } + + // MARK: - Private Helpers + + private func makeAuthRequest() -> SFSDKAuthRequest { + let request = SFSDKAuthRequest() + request.oauthClientId = "testClientId" + request.oauthCompletionUrl = "test://callback" + request.loginHost = "test.salesforce.com" + return request + } + + private func createTestAppIdentity() { + SalesforceManager.shared.bootConfig?.remoteAccessConsumerKey = "test_connected_app_id" + SalesforceManager.shared.bootConfig?.oauthRedirectURI = "test://callback" + SalesforceManager.shared.bootConfig?.oauthScopes = Set(["web", "api"]) + UserAccountManager.shared.oauthClientID = "test_connected_app_id" + } +} + +// MARK: - SFOAuthCoordinatorDelegate conformance for tests + +extension LoginForAdminTests: SFOAuthCoordinatorDelegate { + func oauthCoordinator(_ coordinator: SFOAuthCoordinator, didBeginAuthenticationWith view: WKWebView) {} + func oauthCoordinator(_ coordinator: SFOAuthCoordinator, didBeginAuthenticationWith session: ASWebAuthenticationSession) {} + func oauthCoordinatorDidBeginNativeAuthentication(_ coordinator: SFOAuthCoordinator) {} + func oauthCoordinatorDidCancelBrowserAuthentication(_ coordinator: SFOAuthCoordinator) {} +} diff --git a/libs/SalesforceSDKCore/SalesforceSDKCoreTests/SalesforceSDKCoreTests-Bridging-Header.h b/libs/SalesforceSDKCore/SalesforceSDKCoreTests/SalesforceSDKCoreTests-Bridging-Header.h index 4ec23d184d..5ec8f70c25 100644 --- a/libs/SalesforceSDKCore/SalesforceSDKCoreTests/SalesforceSDKCoreTests-Bridging-Header.h +++ b/libs/SalesforceSDKCore/SalesforceSDKCoreTests/SalesforceSDKCoreTests-Bridging-Header.h @@ -2,4 +2,10 @@ // Use this file to import your target's public headers that you would like to expose to Swift. // #import +#import #import "SFSDKLogoutBlocker.h" +#import "SFSDKAuthRequest.h" +#import "SFSDKAuthSession.h" +#import "SFOAuthCoordinator+Internal.h" +#import "SFUserAccountManager+Internal.h" +#import "SFOAuthCredentials+Internal.h" diff --git a/native/SampleApps/AuthFlowTester/AuthFlowTesterUITests/PageObjects/LoginPageObject.swift b/native/SampleApps/AuthFlowTester/AuthFlowTesterUITests/PageObjects/LoginPageObject.swift index 6c768a0b03..8012c14b8a 100644 --- a/native/SampleApps/AuthFlowTester/AuthFlowTesterUITests/PageObjects/LoginPageObject.swift +++ b/native/SampleApps/AuthFlowTester/AuthFlowTesterUITests/PageObjects/LoginPageObject.swift @@ -98,6 +98,26 @@ class LoginPageObject { usernameField().typeText(XCUIKeyboardKey.return.rawValue) tapIfPresent(allowButton()) } + + /// Performs login via the "Login for Admin" flow. + /// Taps Settings → "Login for Admin" which triggers ASWebAuthenticationSession (native browser), + /// then completes authentication in the browser. + func performLoginForAdmin(username: String, password: String) { + // Tap Settings gear icon → "Login for Admin" menu item + tap(settingsButton()) + tap(loginForAdminButton()) + + // Wait for ASWebAuthenticationSession browser to appear + let topBar = app.otherElements["TopBrowserBar"] + _ = topBar.waitForExistence(timeout: UITestTimeouts.long) + + // Complete login in the browser (same interaction as advanced auth) + setTextField(usernameField(), value: username) + usernameField().typeText(XCUIKeyboardKey.return.rawValue) + setTextField(passwordField(), value: password) + passwordField().typeText(XCUIKeyboardKey.return.rawValue) + tapIfPresent(allowButton()) + } func performWelcomeLogin(password: String) { setTextField(passwordField(), value: password) @@ -148,6 +168,10 @@ class LoginPageObject { private func loginOptionsButton() -> XCUIElement { return app.buttons["Login Options"] } + + private func loginForAdminButton() -> XCUIElement { + return app.buttons["Login for Admin"] + } private func changeServerNavigationBar() -> XCUIElement { return app.navigationBars["Change Server"] diff --git a/native/SampleApps/AuthFlowTester/AuthFlowTesterUITests/Tests/LoginForAdminTests.swift b/native/SampleApps/AuthFlowTester/AuthFlowTesterUITests/Tests/LoginForAdminTests.swift new file mode 100644 index 0000000000..fb513aa23a --- /dev/null +++ b/native/SampleApps/AuthFlowTester/AuthFlowTesterUITests/Tests/LoginForAdminTests.swift @@ -0,0 +1,67 @@ +/* + LoginForAdminTests.swift + AuthFlowTesterUITests + + Copyright (c) 2026-present, salesforce.com, inc. All rights reserved. + + Redistribution and use of this software in source and binary forms, with or without modification, + are permitted provided that the following conditions are met: + * Redistributions of source code must retain the above copyright notice, this list of conditions + and the following disclaimer. + * Redistributions in binary form must reproduce the above copyright notice, this list of + conditions and the following disclaimer in the documentation and/or other materials provided + with the distribution. + * Neither the name of salesforce.com, inc. nor the names of its contributors may be used to + endorse or promote products derived from this software without specific prior written + permission of salesforce.com, inc. + + THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR + IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND + FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR + CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, + WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY + WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +import XCTest + +/// Tests for the "Login for Admin" feature which forces browser-based (ASWebAuthenticationSession) +/// authentication to support phishing-resistant MFA for admin users. +/// +/// The "Login for Admin" menu item in the login screen's Settings gear icon forces the +/// web server OAuth flow via native browser, regardless of the app's configured auth flow. +/// +/// These tests verify Login for Admin works: +/// - With web server flow enabled (default) +/// - With web server flow disabled (user agent flow) - Login for Admin should still use web server flow +/// +/// NB: Tests use the first user from ui_test_config.json +/// +class LoginForAdminTests: BaseAuthFlowTester { + + // MARK: - Login for Admin with Web Server Flow Enabled + + /// Login for Admin with ECA opaque app, web server flow enabled (default). + /// Verifies the admin browser auth flow works when web server flow is already the default. + func testLoginForAdmin_WebServerFlowEnabled() throws { + launchLoginAndValidate( + staticAppConfigName: .ecaOpaque, + loginForAdmin: true + ) + } + + // MARK: - Login for Admin with Web Server Flow Disabled + + /// Login for Admin with ECA opaque app, web server flow disabled. + /// Verifies Login for Admin forces web server flow even when the app is configured + /// to use user agent flow. + func testLoginForAdmin_WebServerFlowDisabled() throws { + launchLoginAndValidate( + staticAppConfigName: .ecaOpaque, + useWebServerFlow: false, + loginForAdmin: true + ) + } +} diff --git a/native/SampleApps/AuthFlowTester/AuthFlowTesterUITests/Util/BaseAuthFlowTester.swift b/native/SampleApps/AuthFlowTester/AuthFlowTesterUITests/Util/BaseAuthFlowTester.swift index 49f3a34333..81d321f959 100644 --- a/native/SampleApps/AuthFlowTester/AuthFlowTesterUITests/Util/BaseAuthFlowTester.swift +++ b/native/SampleApps/AuthFlowTester/AuthFlowTesterUITests/Util/BaseAuthFlowTester.swift @@ -93,6 +93,7 @@ class BaseAuthFlowTester: XCTestCase { /// - useWebServerFlow: Whether to use web server OAuth flow. Defaults to `true`. /// - useHybridFlow: Whether to use hybrid authentication flow. Defaults to `true`. /// - useWelcomeDiscovery: When true, configures simulated domain discovery. Defaults to `false`. + /// - loginForAdmin: When true, uses the "Login for Admin" flow (browser-based auth via Settings menu). Defaults to `false`. func login( loginHost: KnownLoginHostConfig, user: KnownUserConfig, @@ -103,6 +104,7 @@ class BaseAuthFlowTester: XCTestCase { useWebServerFlow: Bool = true, useHybridFlow: Bool = true, useWelcomeDiscovery: Bool = false, + loginForAdmin: Bool = false, ) { let userConfig = getUser(loginHost: loginHost, user: user) let hostConfig = getLoginHost(loginHost: loginHost) @@ -136,8 +138,12 @@ class BaseAuthFlowTester: XCTestCase { return } + // Login for Admin (browser-based auth via Settings menu) + if (loginForAdmin) { + loginPage.performLoginForAdmin(username: userConfig.username, password: userConfig.password) + } // Welcome login - if (useWelcomeDiscovery) { + else if (useWelcomeDiscovery) { XCTAssertTrue(loginPage.hasFilledUsernameField(username: userConfig.username), "Login page should have pre-filled username") loginPage.performWelcomeLogin(password: userConfig.password) } @@ -263,10 +269,11 @@ class BaseAuthFlowTester: XCTestCase { dynamicScopeSelection: ScopeSelection = .empty, useWebServerFlow: Bool = true, useHybridFlow: Bool = true, + loginForAdmin: Bool = false, ) { // Launch launch() - + // Login login( loginHost: loginHost, @@ -277,6 +284,7 @@ class BaseAuthFlowTester: XCTestCase { dynamicScopeSelection: dynamicScopeSelection, useWebServerFlow: useWebServerFlow, useHybridFlow: useHybridFlow, + loginForAdmin: loginForAdmin, ) } @@ -305,14 +313,15 @@ class BaseAuthFlowTester: XCTestCase { useWebServerFlow: Bool = true, useHybridFlow: Bool = true, useWelcomeDiscovery: Bool = false, + loginForAdmin: Bool = false, ) { let useStaticConfiguration = dynamicAppConfigName == nil let userAppConfigName = useStaticConfiguration ? staticAppConfigName : dynamicAppConfigName! let userScopeSelection = useStaticConfiguration ? staticScopeSelection : dynamicScopeSelection - + // Launch launch() - + // Login login( loginHost: loginHost, @@ -323,10 +332,13 @@ class BaseAuthFlowTester: XCTestCase { dynamicScopeSelection: dynamicScopeSelection, useWebServerFlow: useWebServerFlow, useHybridFlow: useHybridFlow, - useWelcomeDiscovery: useWelcomeDiscovery + useWelcomeDiscovery: useWelcomeDiscovery, + loginForAdmin: loginForAdmin ) - + // Validate + // Login for Admin always uses web server flow regardless of the useWebServerFlow setting + let effectiveUseWebServerFlow = loginForAdmin || useWebServerFlow validate( loginHost: loginHost, user: user, @@ -334,7 +346,7 @@ class BaseAuthFlowTester: XCTestCase { staticScopeSelection: staticScopeSelection, userAppConfigName: userAppConfigName, userScopeSelection: userScopeSelection, - useWebServerFlow: useWebServerFlow, + useWebServerFlow: effectiveUseWebServerFlow, useHybridFlow: useHybridFlow ) } diff --git a/shared/resources/SalesforceSDKResources.bundle/en.lproj/Localizable.strings b/shared/resources/SalesforceSDKResources.bundle/en.lproj/Localizable.strings index 2962ebb46d..0e29c6ccc1 100644 --- a/shared/resources/SalesforceSDKResources.bundle/en.lproj/Localizable.strings +++ b/shared/resources/SalesforceSDKResources.bundle/en.lproj/Localizable.strings @@ -68,6 +68,7 @@ "LOGIN_SETTINGS_BUTTON" = "Settings"; "LOGIN_OPTIONS" = "Login Options"; +"LOGIN_FOR_ADMIN" = "Login for Admin"; "LOGIN_CLEAR_COOKIES" = "Clear Cookies"; "LOGIN_CLEAR_CACHE" = "Clear Cache"; "LOGIN_RELOAD" = "Reload"; From 64799cd67d6da13be55be75a3c970ba7bb6665ae Mon Sep 17 00:00:00 2001 From: Brandon Page Date: Tue, 5 May 2026 16:47:59 -0700 Subject: [PATCH 2/3] Add public API to trigger Login for Admin flow. (#4026) (#4028) * Add public API to trigger Login for Admin flow. * Improve code documentation for loginViewControllerDidSelectLoginForAdmin. --- .../project.pbxproj | 2 +- .../UserAccount/SFUserAccountManager.h | 29 ++++++++++++++++ .../LoginForAdminTests.swift | 34 +++++++++++++++++++ 3 files changed, 64 insertions(+), 1 deletion(-) diff --git a/libs/SalesforceSDKCore/SalesforceSDKCore.xcodeproj/project.pbxproj b/libs/SalesforceSDKCore/SalesforceSDKCore.xcodeproj/project.pbxproj index 13ac408f67..8240d15800 100644 --- a/libs/SalesforceSDKCore/SalesforceSDKCore.xcodeproj/project.pbxproj +++ b/libs/SalesforceSDKCore/SalesforceSDKCore.xcodeproj/project.pbxproj @@ -79,7 +79,7 @@ 4F3139682331C5C7007B3705 /* SFSDKAuthRootController.h in Headers */ = {isa = PBXBuildFile; fileRef = 4F3139672331C5B9007B3705 /* SFSDKAuthRootController.h */; }; 4F3ECD8A2EBBD150005020A6 /* SFOAuthCoordinatorTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 4F3ECD892EBBD150005020A6 /* SFOAuthCoordinatorTests.m */; }; 4F3ECD8C2EBBD182005020A6 /* SFOAuthInfoTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 4F3ECD8B2EBBD182005020A6 /* SFOAuthInfoTests.m */; }; - 4FA1B2C32F0E000000000001 /* LoginForAdminTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FA1B2C32F0E000000000002 /* LoginForAdminTests.m */; }; + 4FA1B2C32F0E000000000001 /* LoginForAdminTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FA1B2C32F0E000000000002 /* LoginForAdminTests.swift */; }; 4F5727E327F27F1A0008CDA4 /* SFSDKPrimingRecordsResponse.h in Headers */ = {isa = PBXBuildFile; fileRef = 4F5727DC27F27F1A0008CDA4 /* SFSDKPrimingRecordsResponse.h */; settings = {ATTRIBUTES = (Public, ); }; }; 4F5727E427F27F1A0008CDA4 /* SFSDKPrimingRecordsResponse.m in Sources */ = {isa = PBXBuildFile; fileRef = 4F5727E227F27F1A0008CDA4 /* SFSDKPrimingRecordsResponse.m */; }; 4F5A49502E98711600C89DDD /* ScopeParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F5A494F2E98711600C89DDD /* ScopeParser.swift */; }; diff --git a/libs/SalesforceSDKCore/SalesforceSDKCore/Classes/UserAccount/SFUserAccountManager.h b/libs/SalesforceSDKCore/SalesforceSDKCore/Classes/UserAccount/SFUserAccountManager.h index b22720b347..b328e839a0 100644 --- a/libs/SalesforceSDKCore/SalesforceSDKCore/Classes/UserAccount/SFUserAccountManager.h +++ b/libs/SalesforceSDKCore/SalesforceSDKCore/Classes/UserAccount/SFUserAccountManager.h @@ -31,6 +31,7 @@ #import @class SFSDKSPConfig; +@class SFLoginViewController; NS_ASSUME_NONNULL_BEGIN @@ -625,6 +626,34 @@ Use this method to stop/clear any authentication which is has already been start statusUpdate:(void(^)(SFSPLoginStatus))statusBlock failure:(void(^)(SFSPLoginError))failureBlock; +/** + Triggers the "Login for Admin" flow for the active login session associated with the given + login view controller. Forces browser-based (advanced) authentication via ASWebAuthenticationSession, + regardless of org configuration, to support phishing-resistant MFA. + + This performs the same action as selecting "Login for Admin" from the login screen's settings + menu. Apps that hide the settings menu (e.g. by setting `showSettingsIcon = NO`) can call this + to expose the feature from their own UI. + + To obtain a reference to the currently presented `SFLoginViewController`, either: + + 1. Capture it from `SFSDKLoginViewControllerConfig.loginViewControllerCreationBlock`, which + the SDK invokes each time it constructs the login view: + + UserAccountManager.shared.loginViewControllerConfig.loginViewControllerCreationBlock = { [weak self] in + let vc = SalesforceLoginViewController() + self?.currentLoginVC = vc // retain weakly for later + return vc + } + + 2. Walk the key window's view hierarchy and locate the `SFLoginViewController` presented + inside the SDK's navigation controller. + + @param loginViewController The login view controller whose scene's active auth session should + switch to "Login for Admin". Its window's scene is used to locate the session. + */ +- (void)loginViewControllerDidSelectLoginForAdmin:(SFLoginViewController *)loginViewController NS_SWIFT_NAME(loginViewControllerDidSelectLoginForAdmin(_:)) SFSDK_DEPRECATED(13.2.1, 14.0, "Will be removed in 14.0 when a permanent solution is provided."); + @end NS_ASSUME_NONNULL_END diff --git a/libs/SalesforceSDKCore/SalesforceSDKCoreTests/LoginForAdminTests.swift b/libs/SalesforceSDKCore/SalesforceSDKCoreTests/LoginForAdminTests.swift index 5459f22dbd..60497edcae 100644 --- a/libs/SalesforceSDKCore/SalesforceSDKCoreTests/LoginForAdminTests.swift +++ b/libs/SalesforceSDKCore/SalesforceSDKCoreTests/LoginForAdminTests.swift @@ -482,6 +482,40 @@ class LoginForAdminTests: XCTestCase { window.rootViewController = nil } + @available(*, deprecated, message: "Exercises deprecated public API") + func testGivenAuthSession_whenPublicLoginForAdminCalled_thenLoginAsAdminSet() { + let uam = UserAccountManager.shared + + guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene else { + XCTFail("Test requires a UIWindowScene from the running test app") + return + } + let sceneId = windowScene.session.persistentIdentifier + + let request = makeAuthRequest() + request.loginAsAdmin = false + let session = SFSDKAuthSession(request, credentials: nil) + uam.authSessions[sceneId as NSString] = session + + XCTAssertFalse(session.oauthRequest.loginAsAdmin, "loginAsAdmin should be false before invoking public API") + + let loginVC = SalesforceLoginViewController() + let window = windowScene.windows.first ?? UIWindow(windowScene: windowScene) + window.rootViewController = loginVC + window.makeKeyAndVisible() + loginVC.loadViewIfNeeded() + + // Invoke directly via the public Swift binding (not performSelector) to confirm the + // method is exposed on UserAccountManager for SDK consumers. + uam.loginViewControllerDidSelectLoginForAdmin(loginVC) + + XCTAssertTrue(session.oauthRequest.loginAsAdmin, + "loginAsAdmin should be true after calling the public loginViewControllerDidSelectLoginForAdmin") + + uam.authSessions.removeObject(sceneId as NSString) + window.rootViewController = nil + } + // MARK: - Private Helpers private func makeAuthRequest() -> SFSDKAuthRequest { From 6ec54370017074bb0af0be3b4ada9b17ba653409 Mon Sep 17 00:00:00 2001 From: Wolfgang Mathurin Date: Fri, 8 May 2026 11:25:29 -0700 Subject: [PATCH 3/3] Remove duplicate performLoginForAdmin method introduced by merge conflict resolution --- .../PageObjects/LoginPageObject.swift | 20 ------------------- 1 file changed, 20 deletions(-) diff --git a/native/SampleApps/AuthFlowTester/AuthFlowTesterUITests/PageObjects/LoginPageObject.swift b/native/SampleApps/AuthFlowTester/AuthFlowTesterUITests/PageObjects/LoginPageObject.swift index 06d99949b1..c504bbb7f5 100644 --- a/native/SampleApps/AuthFlowTester/AuthFlowTesterUITests/PageObjects/LoginPageObject.swift +++ b/native/SampleApps/AuthFlowTester/AuthFlowTesterUITests/PageObjects/LoginPageObject.swift @@ -96,26 +96,6 @@ class LoginPageObject { tapIfPresent(allowButton()) } - /// Performs login via the "Login for Admin" flow. - /// Taps Settings → "Login for Admin" which triggers ASWebAuthenticationSession (native browser), - /// then completes authentication in the browser. - func performLoginForAdmin(username: String, password: String) { - // Tap Settings gear icon → "Login for Admin" menu item - tap(settingsButton()) - tap(loginForAdminButton()) - - // Wait for ASWebAuthenticationSession browser to appear - let topBar = app.otherElements["TopBrowserBar"] - _ = topBar.waitForExistence(timeout: UITestTimeouts.long) - - // Complete login in the browser (same interaction as advanced auth) - setTextField(usernameField(), value: username) - usernameField().typeText(XCUIKeyboardKey.return.rawValue) - setTextField(passwordField(), value: password) - passwordField().typeText(XCUIKeyboardKey.return.rawValue) - tapIfPresent(allowButton()) - } - /// Performs login via the "Login for Admin" flow. /// Taps Settings → "Login for Admin" which triggers ASWebAuthenticationSession (native browser), /// then completes authentication in the browser.