Kinvey/swift-sdk

View on GitHub
Kinvey/Kinvey/MIC.swift

Summary

Maintainability
D
2 days
Test Coverage
//
//  MIC.swift
//  Kinvey
//
//  Created by Victor Hugo on 2017-01-18.
//  Copyright © 2017 Kinvey. All rights reserved.
//

import Foundation
import PromiseKit

class URLSessionDelegateAdapter : NSObject, URLSessionTaskDelegate {
    
    func urlSession(_ session: URLSession, task: URLSessionTask, willPerformHTTPRedirection response: HTTPURLResponse, newRequest request: URLRequest, completionHandler: @escaping (URLRequest?) -> Void) {
        completionHandler(nil)
    }
    
}

/// Class that handles Mobile Identity Connect (MIC) calls
open class MIC {
    
    private init() {
    }
    
    /**
     Validate if a URL matches for a redirect URI and also contains a code value
     */
    open class func isValid(redirectURI: URL, url: URL) -> Bool {
        switch parseCode(redirectURI: redirectURI, url: url) {
        case .success(_):
            return true
        case .failure(_):
            return false
        }
    }
    
    class func parseCode(redirectURI: URL, url: URL) -> Swift.Result<String, Swift.Error> {
        guard redirectURI.scheme?.lowercased() == url.scheme?.lowercased(),
            redirectURI.host?.lowercased() == url.host?.lowercased(),
            let urlComponents = URLComponents(url: url, resolvingAgainstBaseURL: false),
            let queryItems = urlComponents.queryItems
        else {
            return .failure(NilError(failure: nil))
        }
        
        var code: String? = nil
        var error: String? = nil
        var errorDescription: String? = nil
        for queryItem in queryItems {
            guard let value = queryItem.value, !value.isEmpty else {
                continue
            }
            switch queryItem.name {
            case "code":
                code = value
            case "error":
                error = value
            case "error_description":
                errorDescription = value
            default:
                break
            }
        }
        
        if let code = code {
            return .success(code)
        } else if let error = error,
            let errorDescription = errorDescription
        {
            return .failure(Error.micAuth(error: error, description: errorDescription))
        }
        return .failure(NilError(failure: nil))
    }
    
    /// Returns a URL that must be used for login with MIC
    open class func urlForLogin(
        redirectURI: URL,
        loginPage: Bool = true,
        options: Options? = nil
    ) -> URL {
        let client = options?.client ?? sharedClient
        return OAuthEndpoint.oauthAuth(
            client: client,
            clientId: options?.authServiceId,
            redirectURI: redirectURI,
            loginPage: loginPage
        ).url
    }
    
    @discardableResult
    class func login<U: User>(
        redirectURI: URL,
        code: String,
        userType: U.Type,
        options: Options? = nil,
        completionHandler: ((Swift.Result<U, Swift.Error>) -> Void)? = nil
    ) -> AnyRequest<Swift.Result<U, Swift.Error>> {
        return login(
            redirectURI: redirectURI,
            code: code,
            options: options,
            completionHandler: completionHandler
        )
    }
    
    @discardableResult
    class func login<U: User>(
        redirectURI: URL,
        code: String,
        options: Options? = nil,
        completionHandler: ((Swift.Result<U, Swift.Error>) -> Void)? = nil
    ) -> AnyRequest<Swift.Result<U, Swift.Error>> {
        let client = options?.client ?? sharedClient
        let requests = MultiRequest<Swift.Result<U, Swift.Error>>()
        Promise<U> { resolver in
            let request = client.networkRequestFactory.oauth.buildOAuthToken(
                redirectURI: redirectURI,
                code: code,
                options: options
            )
            request.execute { (data, response, error) in
                if let response = response,
                    response.isOK,
                    let data = data,
                    let authData = try? client.jsonParser.parseDictionary(from: data)
                {
                    requests += User.login(
                        authSource: .kinvey,
                        authData,
                        options: options,
                        completionHandler: resolver.completionHandler()
                    )
                } else {
                    resolver.reject(buildError(data, response, error, client))
                }
            }
            requests += request
        }.done { user in
            completionHandler?(.success(user))
        }.catch { error in
            completionHandler?(.failure(error))
        }
        return AnyRequest(requests)
    }
    
    private class func oauthGrantAuthenticate<U: User>(
        redirectURI: URL,
        username: String,
        password: String,
        options: Options?,
        requests: MultiRequest<Swift.Result<U, Swift.Error>>,
        tempLoginUrl: URL
    ) -> Promise<U> {
        let client = options?.client ?? sharedClient
        return Promise<U> { resolver in
            let request = client.networkRequestFactory.oauth.buildOAuthGrantAuthenticate(
                redirectURI: redirectURI,
                tempLoginUri: tempLoginUrl,
                username: username,
                password: password,
                options: options
            )
            let urlSession = options?.urlSession ?? URLSession(
                configuration: client.urlSession.configuration,
                delegate: URLSessionDelegateAdapter(),
                delegateQueue: nil
            )
            request.execute(urlSession: urlSession) { (data, response, error) in
                defer {
                    urlSession.invalidateAndCancel()
                }
                guard let httpResponse = response as? HttpResponse,
                    httpResponse.response.statusCode == 302,
                    let location = httpResponse.response.allHeaderFields["Location"] as? String,
                    let url = URL(string: location)
                else {
                    resolver.reject(buildError(data, response, error, client))
                    return
                }
                switch parseCode(redirectURI: redirectURI, url: url) {
                case .success(let code):
                    requests += login(
                        redirectURI: redirectURI,
                        code: code,
                        userType: U.self,
                        options: options,
                        completionHandler: resolver.completionHandler()
                    )
                case .failure(var error):
                    if error is NilError {
                        error = buildError(data, response, error, client)
                    }
                    resolver.reject(error)
                }
            }
            requests += request
        }
    }
    
    @discardableResult
    class func login<U: User>(
        redirectURI: URL,
        username: String,
        password: String,
        options: Options? = nil,
        completionHandler: ((Swift.Result<U, Swift.Error>) -> Void)? = nil
    ) -> AnyRequest<Swift.Result<U, Swift.Error>> {
        let client = options?.client ?? sharedClient
        let requests = MultiRequest<Swift.Result<U, Swift.Error>>()
        let request = client.networkRequestFactory.oauth.buildOAuthGrantAuth(
            redirectURI: redirectURI,
            options: options
        )
        Promise<URL> { resolver in
            request.execute { (data, response, error) in
                if let response = response,
                    response.isOK,
                    let data = data,
                    let json = try? client.jsonParser.parseDictionary(from: data),
                    let tempLoginUri = json["temp_login_uri"] as? String,
                    let tempLoginUrl = URL(string: tempLoginUri)
                {
                    resolver.fulfill(tempLoginUrl)
                } else {
                    resolver.reject(buildError(data, response, error, client))
                }
            }
            requests += request
        }.then { tempLoginUrl in
            return oauthGrantAuthenticate(
                redirectURI: redirectURI,
                username: username,
                password: password,
                options: options,
                requests: requests,
                tempLoginUrl: tempLoginUrl
            )
        }.done { user -> Void in
            completionHandler?(.success(user))
        }.catch { error in
            completionHandler?(.failure(error))
        }
        return AnyRequest(requests)
    }
    
    @discardableResult
    class func login<U: User>(
        username: String,
        password: String,
        options: Options? = nil,
        completionHandler: ((Swift.Result<U, Swift.Error>) -> Void)? = nil
    ) -> AnyRequest<Swift.Result<U, Swift.Error>> {
        let client = options?.client ?? sharedClient
        let requests = MultiRequest<Swift.Result<U, Swift.Error>>()
        let request = client.networkRequestFactory.oauth.buildOAuthToken(
            username: username,
            password: password,
            options: options
        )
        requests += request
        Promise<JsonDictionary> { resolver in
            request.execute { (data, response, error) in
                if let response = response,
                    response.isOK,
                    let data = data,
                    let json = try? client.jsonParser.parseDictionary(from: data)
                {
                    resolver.fulfill(json)
                } else {
                    resolver.reject(error ?? buildError(data, response, error, client))
                }
            }
        }.then { socialIdentity in
            return Promise<U> { resolver in
                requests += User.login(
                    authSource: .kinvey,
                    socialIdentity
                ) { result in
                    switch result {
                    case .success(let user):
                        resolver.fulfill(user as! U)
                    case .failure(let error):
                        resolver.reject(error)
                    }
                }
            }
        }.done { user -> Void in
            completionHandler?(.success(user))
        }.catch { error in
            completionHandler?(.failure(error))
        }
        return AnyRequest(requests)
    }
    
    @discardableResult
    class func login<U: User>(
        refreshToken: String,
        options: Options?,
        completionHandler: ((Swift.Result<U, Swift.Error>) -> Void)? = nil
    ) -> AnyRequest<Swift.Result<U, Swift.Error>> {
        let requests = MultiRequest<Swift.Result<U, Swift.Error>>()
        let client = options?.client ?? sharedClient
        let request = client.networkRequestFactory.oauth.buildOAuthGrantRefreshToken(
            refreshToken: refreshToken,
            options: options
        )
        request.execute { (data, response, error) in
            if let response = response,
                response.isOK,
                let data = data,
                let authData = try? client.jsonParser.parseDictionary(from: data)
            {
                requests += User.login(authSource: .kinvey, authData, options: options, completionHandler: completionHandler)
            } else {
                completionHandler?(.failure(buildError(data, response, error, client)))
            }
        }
        requests += request
        return AnyRequest(requests)
    }
    
}

/// Used to tell which user interface must be used during the login process using MIC.
public enum MICUserInterface {
    
    /// Uses SFSafariViewController
    case safari

    /// Uses SFAuthenticationSession if running on iOS 11 and above, otherwise uses SFSafariViewController instead
    case safariAuthenticationSession
    
    /// Uses WKWebView
    case wkWebView
    
    /// Default Value: .safari
    public static let `default`: MICUserInterface = .safariAuthenticationSession
    
}

/// Specifies which version of the MIC API will be used.
public enum MICApiVersion: String {
    
    /// Version 1
    case v1
    
    /// Version 2
    case v2
    
    /// Version 3
    case v3
    
}

#if os(iOS)

import UIKit
import WebKit

class MICLoginViewController: UIViewController {
    
    typealias UserHandler<U: User> = (Swift.Result<U, Swift.Error>) -> Void
    
    lazy var activityIndicatorView: UIActivityIndicatorView = {
        let activityIndicatorView = UIActivityIndicatorView(style: .whiteLarge)
        activityIndicatorView.translatesAutoresizingMaskIntoConstraints = false
        activityIndicatorView.hidesWhenStopped = true
        activityIndicatorView.backgroundColor = UIColor(white: 0, alpha: 0.5)
        activityIndicatorView.layer.cornerRadius = 8
        activityIndicatorView.layer.masksToBounds = true
        let rect = activityIndicatorView.bounds.insetBy(dx: -8, dy: -8)
        activityIndicatorView.bounds = CGRect(origin: CGPoint.zero, size: rect.size)
        return activityIndicatorView
    }()
    
    var redirectURI: URL!
    var options: Options?
    var completionHandler: UserHandler<User>!
    
    @objc
    lazy var webView: WKWebView = {
        let webView = WKWebView()
        webView.navigationDelegate = self
        webView.translatesAutoresizingMaskIntoConstraints = false
        webView.accessibilityIdentifier = "Web View"
        return webView
    }()
    
    var timer: Timer? {
        willSet {
            if let timer = timer, timer.isValid {
                timer.invalidate()
            }
        }
    }
    
    convenience init<UserType: User>(
        redirectURI: URL,
        userType: UserType.Type,
        options: Options?,
        completionHandler: @escaping UserHandler<UserType>
    ) {
        self.init(nibName: nil, bundle: nil)
        self.redirectURI = redirectURI
        self.options = options
        self.completionHandler = {
            switch $0 {
            case .success(let user):
                completionHandler(.success(user as! UserType))
            case .failure(let error):
                completionHandler(.failure(error))
            }
        }
    }
    
    private func addWebView() {
        view.addSubview(webView)
        
        let views = [
            "webView" : webView
        ]
        
        view.addConstraints(NSLayoutConstraint.constraints(
            withVisualFormat: "H:|[webView]|",
            metrics: nil,
            views: views
        ))
        
        view.addConstraints(NSLayoutConstraint.constraints(
            withVisualFormat: "V:|[webView]|",
            metrics: nil,
            views: views
        ))
    }
    
    private lazy var activityIndicatorViewWidthLayoutConstraint = NSLayoutConstraint(
        item: activityIndicatorView,
        attribute: .width,
        relatedBy: .equal,
        toItem: nil,
        attribute: .notAnAttribute,
        multiplier: 1,
        constant: activityIndicatorView.bounds.size.width
    )
    
    private lazy var activityIndicatorViewHeightLayoutConstraint = NSLayoutConstraint(
        item: activityIndicatorView,
        attribute: .height,
        relatedBy: .equal,
        toItem: nil,
        attribute: .notAnAttribute,
        multiplier: 1,
        constant: activityIndicatorView.bounds.size.height
    )
    
    private lazy var activityIndicatorViewCenterXLayoutConstraint = NSLayoutConstraint(
        item: activityIndicatorView,
        attribute: .centerX,
        relatedBy: .equal,
        toItem: view,
        attribute: .centerX,
        multiplier: 1,
        constant: 0
    )
    
    private lazy var activityIndicatorViewCenterYLayoutConstraint = NSLayoutConstraint(
        item: activityIndicatorView,
        attribute: .centerY,
        relatedBy: .equal,
        toItem: view,
        attribute: .centerY,
        multiplier: 1,
        constant: 0
    )
    
    private func addActivityIndicatorView() {
        view.insertSubview(activityIndicatorView, aboveSubview: webView)
        
        activityIndicatorView.addConstraint(activityIndicatorViewWidthLayoutConstraint)
        activityIndicatorView.addConstraint(activityIndicatorViewHeightLayoutConstraint)
        
        view.addConstraint(activityIndicatorViewCenterXLayoutConstraint)
        view.addConstraint(activityIndicatorViewCenterYLayoutConstraint)
    }
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        navigationItem.leftBarButtonItem = UIBarButtonItem(
            title: " X ",
            style: .plain,
            target: self,
            action: #selector(closeViewControllerUserInteractionCancel(_:))
        )
        
        navigationItem.rightBarButtonItem = UIBarButtonItem(
            barButtonSystemItem: .refresh,
            target: self,
            action: #selector(refreshPage(_:))
        )
        
        addWebView()
        addActivityIndicatorView()
    }
    
    override func viewWillAppear(_ animated: Bool) {
        super.viewWillAppear(animated)
        
        let url = MIC.urlForLogin(redirectURI: redirectURI, options: options)
        let request = URLRequest(url: url)
        webView.load(request)
        
        if let timeout = options?.timeout, timeout > 0 {
            timer = Timer.scheduledTimer(
                timeInterval: timeout,
                target: self,
                selector: #selector(closeViewControllerUserInteractionTimeout(_:)),
                userInfo: nil,
                repeats: false
            )
        }
    }
    
    @objc
    func closeViewControllerUserInteractionCancel(_ sender: Any) {
        closeViewControllerUserInteraction(.failure(Error.requestCancelled))
    }
    
    @objc
    func closeViewControllerUserInteractionTimeout(_ sender: Any) {
        closeViewControllerUserInteraction(.failure(Error.requestTimeout))
    }
    
    func closeViewControllerUserInteraction(_ result: Swift.Result<User, Swift.Error>) {
        timer = nil
        dismiss(animated: true) {
            self.completionHandler(result)
        }
    }
    
    @objc
    func refreshPage(_ sender: Any) {
        webView.reload()
    }
    
    func success(code: String) {
        activityIndicatorView.startAnimating()
        
        MIC.login(
            redirectURI: redirectURI,
            code: code,
            options: options
        ) { result in
            self.activityIndicatorView.stopAnimating()
            
            self.closeViewControllerUserInteraction(result)
        }
    }
    
    func failure(error: Swift.Error) {
        let url = (error as NSError).userInfo[NSURLErrorFailingURLErrorKey] as? URL
        if url == nil || !MIC.isValid(redirectURI: redirectURI, url: url!) {
            activityIndicatorView.stopAnimating()
            
            closeViewControllerUserInteraction(.failure(error))
        }
    }
    
    func handleError(body: String?) {
        if let body = body,
            let data = body.data(using: .utf8),
            let object = try? JSONSerialization.jsonObject(with: data),
            let json = object as? JsonDictionary
        {
            failure(error: Error.unknownJsonError(httpResponse: nil, data: nil, json: json))
        }
    }
    
}

extension MICLoginViewController: WKNavigationDelegate {
    
    func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
        var navigationActionPolicy: WKNavigationActionPolicy = .allow
        if let url = navigationAction.request.url {
            switch MIC.parseCode(redirectURI: redirectURI, url: url) {
            case .success(let code):
                success(code: code)
                
                navigationActionPolicy = .cancel
            case .failure(let error):
                if !(error is NilError) {
                    failure(error: error)
                    
                    navigationActionPolicy = .cancel
                }
            }
        }
        decisionHandler(navigationActionPolicy)
    }
    
    func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation!) {
        activityIndicatorView.startAnimating()
    }
    
    func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Swift.Error) {
        failure(error: error)
    }
    
    func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Swift.Error) {
        failure(error: error)
    }
    
    func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
        webView.evaluateJavaScript("document.body.innerText") { body, error in
            if let body = body as? String {
                self.handleError(body: body)
            }
        }
        
        activityIndicatorView.stopAnimating()
    }
    
}

#endif