AzureAD / microsoft-authentication-library-for-objc

Microsoft Authentication Library (MSAL) for iOS and macOS
http://aka.ms/aadv2
MIT License
259 stars 141 forks source link

SwiftUI support for fetching tokens interactively without using ViewController #1437

Open dpaulino opened 2 years ago

dpaulino commented 2 years ago

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 commented 2 years ago

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 commented 2 years ago

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 commented 2 years ago

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[bot] commented 2 years ago

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.

NilsLattek commented 2 years ago

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

ljunquera commented 2 years ago

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

antrix1989 commented 2 years ago

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

jihad2022 commented 2 years ago

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 commented 2 years ago

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 commented 2 years ago

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 commented 1 year ago

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 commented 10 months ago

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

carr0495 commented 6 months ago

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 commented 5 months ago

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 commented 5 months ago

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 commented 5 months ago

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 commented 5 months ago

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 commented 2 months ago

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.