Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

SwiftUI support for fetching tokens interactively without using ViewController #1437

Open
dpaulino opened this issue Jan 31, 2022 · 19 comments
Assignees

Comments

@dpaulino
Copy link

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

@antrix1989 antrix1989 self-assigned this Feb 3, 2022
@antrix1989
Copy link
Contributor

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

@dpaulino
Copy link
Author

dpaulino commented Feb 4, 2022

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

@mfcollins3
Copy link

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

@stale
Copy link

stale bot commented Mar 2, 2022

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 stale bot added the stale-issue label Mar 2, 2022
@NilsLattek
Copy link

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

@stale stale bot removed the stale-issue label Mar 2, 2022
@ljunquera
Copy link

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

@antrix1989
Copy link
Contributor

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

@jihad2022
Copy link

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
Copy link

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.

@SylvanG
Copy link

SylvanG commented Sep 12, 2022

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

@ljunquera
Copy link

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.

@igenta-applaudo
Copy link

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

@carr0495
Copy link

carr0495 commented Mar 7, 2024

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

@keenan-chiasson
Copy link

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...

@legoesbenr
Copy link

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
Copy link

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)
    }
}

@keenan-chiasson
Copy link

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!

@ShmuelCammebys
Copy link

The issue we're having is that in application.acquireToken(completionBlock: { [weak self] result, error in, self can sometimes be null because the view controller is null, so I can't update my @State variables.

@ShmuelCammebys
Copy link

@legoesbenr Can you clarify the type of authController and how you are implementing Container?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests