microsoft-authentication-library-for-objc icon indicating copy to clipboard operation
microsoft-authentication-library-for-objc copied to clipboard

SwiftUI support for fetching tokens interactively without using ViewController

Open dpaulino opened this issue 3 years ago • 18 comments

Hi there,

I want to integrate MSAL into my new SwiftUI app, but based on the sample code, it seems that I need to work with UIKit. I'm not familiar with UIKit at all. How do I get a token interactively using just SwiftUI paradigms?

Here's the sample code that I'm confused about, since SwiftUI doesn't use controllers:

Screen Shot 2022-01-30 at 9 17 51 PM

dpaulino avatar Jan 31 '22 05:01 dpaulino

hi @dpaulino, we don't have SwiftUI sample at the moment. I recommend you to take a look at this tutorial: "Interfacing with UIKit".

antrix1989 avatar Feb 04 '22 00:02 antrix1989

Is there any plan to support SwiftUI? Apple seems heavily invested in this moving forward, and new devs like me only or mostly know SwiftUI. Without support, my colleagues and I may not be able to add Microsoft logins to our products

dpaulino avatar Feb 04 '22 00:02 dpaulino

I posted this last night for authenticating against a B2C tenant. The code snippets show everything that you need: https://medium.com/neudesic-innovation/using-azure-ad-b2c-to-authenticate-ios-app-users-ef3f82435f7d

mfcollins3 avatar Feb 08 '22 21:02 mfcollins3

This issue has been automatically marked as stale because it has not had recent activity. Please provide additional information if requested. Thank you for your contributions.

stale[bot] avatar Mar 02 '22 08:03 stale[bot]

Full SwiftUI support would be awesome. Apple is really pushing SwiftUI.

NilsLattek avatar Mar 02 '22 09:03 NilsLattek

@kaisong1990 and @antrix1989 and word on this feature? Do we have a target date for this?

ljunquera avatar May 16 '22 12:05 ljunquera

We don't have the ETA at the moment, but we are always encouraging contributions.

antrix1989 avatar May 19 '22 22:05 antrix1989

Hi y'all,

I'm trying to integrate MSAL with SwiftUI app as well.

I'm acquiring the token interactively from one of my SwiftUI Views upon appearing, see code below:

.onAppear {
       viewModel.startEnrollment(with: self)
 }

In my view model I've this code:

class MainViewModel: ObservableObject {
    @Published private (set) var accessToken: String = ""
    @Published private (set) var error: Error?
    var bag: Set<AnyCancellable> = Set<AnyCancellable>()
    
    private var enrollmentManager: EnrollmentManager
    
    init(enrollmentManager: EnrollmentManager = EnrollmentManager.shared) {
        self.enrollmentManager = enrollmentManager
    }
    
    func startEnrollment<T: View>(with view: T) {
        do {
            let application = try enrollmentManager.create()
            enrollmentManager.acquireToken(for: application, target: view).sink(receiveCompletion: { [weak self] completion in
                switch completion {
                case .finished:
                    return
                case .failure(let error):
                    self?.error = error
                }
            }) { [weak self] (accessToken: String, accountIdentifier: String?) in
                self?.accessToken = accessToken
            }.store(in: &bag)
        } catch {
            self.error = error
        }
    }
}

the self here is the SwiftUI view, and on my enrollment service/manager I've something like this:

func acquireToken<T: View>(for application: MSALPublicClientApplication, target: T) -> AnyPublisher<(accessToken: String, accountIdentifier: String?), Error> {
        let resultPublisher: PassthroughSubject<(accessToken: String, accountIdentifier: String?), Error> = PassthroughSubject<(accessToken: String, accountIdentifier: String?), Error>()
        
        let viewController = UIHostingController(rootView: target)
        let webViewParameters = MSALWebviewParameters(authPresentationViewController: viewController)
        let interactiveParameters = MSALInteractiveTokenParameters(scopes: [], webviewParameters: webViewParameters)
        
        application.acquireToken(with: interactiveParameters) { (result, error) in
            guard let result = result, error == nil else {
                resultPublisher.send(completion: .failure(error!))
                return
            }
            
            let accessToken = result.accessToken
            let accountIdentifier = result.account.identifier
            resultPublisher.send((accessToken, accountIdentifier))
        }
        return resultPublisher.eraseToAnyPublisher()
    }

Unfortunately, I'm getting the following error: Error Domain=MSALErrorDomain Code=-50000 "(null)" UserInfo={MSALErrorDescriptionKey=parentViewController has no window! Provide a valid controller with view and window., MSALInternalErrorCodeKey=-42000} Any idea on why this is happening and how to fix it? Because this seems to be a bug for me as I'm providing the parentViewController as a UIViewController here.

jihad2022 avatar Jun 27 '22 08:06 jihad2022

Hi y'all,

I'm trying to integrate MSAL with SwiftUI app as well.

I'm acquiring the token interactively from one of my SwiftUI Views upon appearing, see code below:

.onAppear {
       viewModel.startEnrollment(with: self)
 }

In my view model I've this code:

class MainViewModel: ObservableObject {
    @Published private (set) var accessToken: String = ""
    @Published private (set) var error: Error?
    var bag: Set<AnyCancellable> = Set<AnyCancellable>()
    
    private var enrollmentManager: EnrollmentManager
    
    init(enrollmentManager: EnrollmentManager = EnrollmentManager.shared) {
        self.enrollmentManager = enrollmentManager
    }
    
    func startEnrollment<T: View>(with view: T) {
        do {
            let application = try enrollmentManager.create()
            enrollmentManager.acquireToken(for: application, target: view).sink(receiveCompletion: { [weak self] completion in
                switch completion {
                case .finished:
                    return
                case .failure(let error):
                    self?.error = error
                }
            }) { [weak self] (accessToken: String, accountIdentifier: String?) in
                self?.accessToken = accessToken
            }.store(in: &bag)
        } catch {
            self.error = error
        }
    }
}

the self here is the SwiftUI view, and on my enrollment service/manager I've something like this:

func acquireToken<T: View>(for application: MSALPublicClientApplication, target: T) -> AnyPublisher<(accessToken: String, accountIdentifier: String?), Error> {
        let resultPublisher: PassthroughSubject<(accessToken: String, accountIdentifier: String?), Error> = PassthroughSubject<(accessToken: String, accountIdentifier: String?), Error>()
        
        let viewController = UIHostingController(rootView: target)
        let webViewParameters = MSALWebviewParameters(authPresentationViewController: viewController)
        let interactiveParameters = MSALInteractiveTokenParameters(scopes: [], webviewParameters: webViewParameters)
        
        application.acquireToken(with: interactiveParameters) { (result, error) in
            guard let result = result, error == nil else {
                resultPublisher.send(completion: .failure(error!))
                return
            }
            
            let accessToken = result.accessToken
            let accountIdentifier = result.account.identifier
            resultPublisher.send((accessToken, accountIdentifier))
        }
        return resultPublisher.eraseToAnyPublisher()
    }

Unfortunately, I'm getting the following error: Error Domain=MSALErrorDomain Code=-50000 "(null)" UserInfo={MSALErrorDescriptionKey=parentViewController has no window! Provide a valid controller with view and window., MSALInternalErrorCodeKey=-42000} Any idea on why this is happening and how to fix it? Because this seems to be a bug for me as I'm providing the parentViewController as a UIViewController here.

This is Working for fresh installation of the App, but not running afterward without signing in on 1st launch of the app. So basically the error is thrown from 2nd launch of the app and up.

jihad2022 avatar Jun 27 '22 09:06 jihad2022

a simple workaround is to create a UIControllerView wrapper View in the SwiftUI.

struct MSALAuthPresentationView: UIViewControllerRepresentable {
    @Binding var showingMSALAuthPresentaion: Bool
    
    func makeUIViewController(context: Context) -> UIMSALAuthPresentationViewController {
        return UIMSALAuthPresentationViewController(showingMSALAuthPresentaion: $showingMSALAuthPresentaion)
    }
    
    func updateUIViewController(_ uiViewController: UIMSALAuthPresentationViewController, context: Context) {
    }
}

class UIMSALAuthPresentationViewController: UIViewController {
    var showingMSALAuthPresentaion: Binding<Bool>
    
    init(showingMSALAuthPresentaion: Binding<Bool>, nibName nibNameOrNil: String? = nil,
         bundle nibBundleOrNil: Bundle? = nil) {
        self.showingMSALAuthPresentaion = showingMSALAuthPresentaion
        super.init(nibName: nibNameOrNil, bundle: nibBundleOrNil)
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    override func viewDidAppear(_ animated: Bool) {
        super.viewDidAppear(animated)
        doAuth()
    }

    private func doAuth() {
        let config = MSALPublicClientApplicationConfig(clientId: "")
        let scopes = ["user.read"]
        let application = try? MSALPublicClientApplication(configuration: config)
        guard let application = application else {
            print("doAuth: load application failed")
            showingMSALAuthPresentaion.wrappedValue = false
            return
        }
        
        #if os(iOS)
            let viewController = self // Pass a reference to the view controller that should be used when getting a token interactively
            let webviewParameters = MSALWebviewParameters(authPresentationViewController: viewController)
        #else
            let webviewParameters = MSALWebviewParameters()
        #endif
        let interactiveParameters = MSALInteractiveTokenParameters(scopes: scopes, webviewParameters: webviewParameters)
        application.acquireToken(with: interactiveParameters, completionBlock: { (result, error) in
            defer {
                self.showingMSALAuthPresentaion.wrappedValue = false
            }
            
            guard let authResult = result, error == nil else {
                print("doAuth acquireToken error: \(error!)")
                return
            }
                        
            // Get access token from result
            let accessToken = authResult.accessToken
                        
            // You'll want to get the account identifier to retrieve and reuse the account for later acquireToken calls
            let accountIdentifier = authResult.account.identifier
        })
    }
}

then put the MSALAuthPresentationView under the ZStack in the parent View

SylvanG avatar Sep 12 '22 10:09 SylvanG

The article by Michael Collins does work, but it really seems like getting it set up and configured is challenging. One of the advantages of other solutions is the simplicity. Is this on the roadmap? It seems like it would be a high priority given iOS mobile development is a pretty significant audience and SwiftUI is the future. This should be a few lines of code and a few configuration settings in an xcode project.

ljunquera avatar Jun 04 '23 16:06 ljunquera

any news? would be awesome if the sdk provide the implementation

igenta-applaudo avatar Oct 18 '23 19:10 igenta-applaudo

Not sure if this would help anyone but I made a repo where I use MSAL with @Environment and @Observable class here: https://github.com/carr0495/MSALSwiftUI/tree/main

The UIViewController is placed at the root of the application and all logic is extracted to an Observable object in the Environment. This allows you to login and logout from anywhere within your SwiftUI Application

carr0495 avatar Mar 07 '24 01:03 carr0495

Simple Workaround

Create a shared static Utilities class for returning the top view controller

import UIKit
import SwiftUI

final class Utilities {
    
    static let shared = Utilities()
    private init() {}
    
    @MainActor
    func topViewController(controller: UIViewController? = nil) -> UIViewController? {
        let controller = controller ?? UIApplication.shared.keyWindow?.rootViewController
        
        if let navigationController = controller as? UINavigationController {
            return topViewController(controller: navigationController.visibleViewController)
        }
        if let tabController = controller as? UITabBarController {
            if let selected = tabController.selectedViewController {
                return topViewController(controller: selected)
            }
        }
        if let presented = controller?.presentedViewController {
            return topViewController(controller: presented)
        }
        return controller
    }
}

Can be implemented like so:

let application = try MSALPublicClientApplication(configuration: config)
                
                guard let viewController = Utilities.shared.topViewController() else { return }
                
                let webviewParameters = MSALWebviewParameters(authPresentationViewController: viewController)
                let interactiveParameters = MSALInteractiveTokenParameters(scopes: scopes, webviewParameters: webviewParameters)
                application.acquireToken(with: interactiveParameters){ result, error in...

keenan-chiasson avatar Mar 27 '24 14:03 keenan-chiasson

Simple Workaround

Create a shared static Utilities class for returning the top view controller

import UIKit
import SwiftUI

final class Utilities {
    
    static let shared = Utilities()
    private init() {}
    
    @MainActor
    func topViewController(controller: UIViewController? = nil) -> UIViewController? {
        let controller = controller ?? UIApplication.shared.keyWindow?.rootViewController
        
        if let navigationController = controller as? UINavigationController {
            return topViewController(controller: navigationController.visibleViewController)
        }
        if let tabController = controller as? UITabBarController {
            if let selected = tabController.selectedViewController {
                return topViewController(controller: selected)
            }
        }
        if let presented = controller?.presentedViewController {
            return topViewController(controller: presented)
        }
        return controller
    }
}

Can be implemented like so:

let application = try MSALPublicClientApplication(configuration: config)
                
                guard let viewController = Utilities.shared.topViewController() else { return }
                
                let webviewParameters = MSALWebviewParameters(authPresentationViewController: viewController)
                let interactiveParameters = MSALInteractiveTokenParameters(scopes: scopes, webviewParameters: webviewParameters)
                application.acquireToken(with: interactiveParameters){ result, error in...

This is a nice workaround, though in iOS16+ it provokes the following warning:

'keyWindow' was deprecated in iOS 13.0: Should not be used for applications that support multiple scenes as it returns a key window across all connected scenes

legoesbenr avatar Mar 27 '24 21:03 legoesbenr

I believe I feel the need to propose another workaround, that works flawlessly as of iOS 16.2+:

In your AppDelegate, setup your UISceneConfiguration with a delegateClass of type sceneDelegate implementing UIWindowSceneDelegate.

In your sceneDelegate cast your UIScene as a UIWindowScene -> keyWindow and from that get the rootViewController. Store the reference somewhere where you can access it or use it to create the MSALWebviewParameters.

Please note that this doesent woth in Preview, but works fine running normally.

import Foundation
import UIKit
import MSAL

class AppDelegate: NSObject, UIApplicationDelegate {
    // Configure MainScene to provide a root UIViewController for MSAL authentication
    
    var test: UIViewController?
    func application(
        _ application: UIApplication,
        configurationForConnecting connectingSceneSession: UISceneSession,
        options: UIScene.ConnectionOptions
    ) -> UISceneConfiguration {
        let configuration = UISceneConfiguration(
            name: "MainScene",
            sessionRole: .windowApplication
        )
        configuration.delegateClass = MainSceneDelegate.self
        return configuration
    }
    
    // Used for MSAL callbacks
    func application(
        _ application: UIApplication,
        open url: URL,
        options: [UIApplication.OpenURLOptionsKey : Any] = [:]
    ) -> Bool {
        return MSALPublicClientApplication.handleMSALResponse(url, sourceApplication: options[UIApplication.OpenURLOptionsKey.sourceApplication] as? String)
    }
}

// MSAL needs to be provided a UIViewController to presents its UI along with a rootVC
class MainSceneDelegate: NSObject, UIWindowSceneDelegate, ObservableObject {
    var authController = Container.authController
    var logController = Container.logController
    
    func scene(
        _ scene: UIScene,
        willConnectTo session: UISceneSession,
        options connectionOptions: UIScene.ConnectionOptions
    ) {
        guard let windowScene = scene as? UIWindowScene, let window = windowScene.keyWindow, let rootViewController = window.rootViewController else {
            logController.log(logString: "MSAL root view controller not found")
            return
        }
        authController.setRootViewController(viewController: rootViewController)
    }
}

legoesbenr avatar Mar 27 '24 23:03 legoesbenr

I believe I feel the need to propose another workaround, that works flawlessly as of iOS 16.2+:

In your AppDelegate, setup your UISceneConfiguration with a delegateClass of type sceneDelegate implementing UIWindowSceneDelegate.

In your sceneDelegate cast your UIScene as a UIWindowScene -> keyWindow and from that get the rootViewController. Store the reference somewhere where you can access it or use it to create the MSALWebviewParameters.

Please note that this doesent woth in Preview, but works fine running normally.

import Foundation
import UIKit
import MSAL

class AppDelegate: NSObject, UIApplicationDelegate {
    // Configure MainScene to provide a root UIViewController for MSAL authentication
    
    var test: UIViewController?
    func application(
        _ application: UIApplication,
        configurationForConnecting connectingSceneSession: UISceneSession,
        options: UIScene.ConnectionOptions
    ) -> UISceneConfiguration {
        let configuration = UISceneConfiguration(
            name: "MainScene",
            sessionRole: .windowApplication
        )
        configuration.delegateClass = MainSceneDelegate.self
        return configuration
    }
    
    // Used for MSAL callbacks
    func application(
        _ application: UIApplication,
        open url: URL,
        options: [UIApplication.OpenURLOptionsKey : Any] = [:]
    ) -> Bool {
        return MSALPublicClientApplication.handleMSALResponse(url, sourceApplication: options[UIApplication.OpenURLOptionsKey.sourceApplication] as? String)
    }
}

// MSAL needs to be provided a UIViewController to presents its UI along with a rootVC
class MainSceneDelegate: NSObject, UIWindowSceneDelegate, ObservableObject {
    var authController = Container.authController
    var logController = Container.logController
    
    func scene(
        _ scene: UIScene,
        willConnectTo session: UISceneSession,
        options connectionOptions: UIScene.ConnectionOptions
    ) {
        guard let windowScene = scene as? UIWindowScene, let window = windowScene.keyWindow, let rootViewController = window.rootViewController else {
            logController.log(logString: "MSAL root view controller not found")
            return
        }
        authController.setRootViewController(viewController: rootViewController)
    }
}

Elegant solution! Implemented it today and it works great!

keenan-chiasson avatar Apr 02 '24 19:04 keenan-chiasson