WebViewからのGoogleへのOAuthリクエスト禁止に対応してSwiftでGoogle Apps APIを使用する

今更かもしれませんが、[iOS Quickstart | Apps Script | Google Developers](https://developers.google.com/apps-script/guides/rest/quickstart/ios?ver=swift)のiOS（Swift）サンプルコードがWebViewからGoogleへのOAuthリクエスト禁止に対応していないので、対応するようにしてみました。

#概要

これまでiOSアプリで、GoogleドライブからGoogle Apps Scriptを使ってデータを取得するのに、Google公式のサンプルコード（[iOS Quickstart | Apps Script | Google Developers](https://developers.google.com/apps-script/guides/rest/quickstart/ios?ver=swift)）を参考にしていたのですが、このサンプルコードはGoogle認証の際にWebViewを利用する方法を取っていました。

というわけで、Googleからアナウンスされた[Modernizing OAuth interactions in Native Apps for Better Usability and Security](https://developers.googleblog.com/2016/08/modernizing-oauth-interactions-in-native-apps.html)の
>In the coming months, we will no longer allow OAuth requests to Google in embedded browsers known as “web-views”.

訳：「ウェブビュー」と言われる埋め込みブラウザから Google への OAuth リクエストは許可されなくなります。

に見事引っかかってしまいます。

なので、[Modernizing OAuth interactions in Native Apps for Better Usability and Security](https://developers.googleblog.com/2016/08/modernizing-oauth-interactions-in-native-apps.html)に案内されている通り、[GTMAppAuth](https://github.com/google/GTMAppAuth)を利用して修正を行いました。

ただ、公開されているコードはObjective-Cだったので、Swiftでの対応ということで、未だ修正されていない（12/13現在）[iOS Quickstart | Apps Script | Google Developers](https://developers.google.com/apps-script/guides/rest/quickstart/ios?ver=swift)のiOS（Swift）のサンプルコードを対応させてみました。

#コード
手順は基本的に[iOS Quickstart | Apps Script | Google Developers](https://developers.google.com/apps-script/guides/rest/quickstart/ios?ver=swift)の通りです。

まず、最初にPodfileの中身が変わります。

```rb:Podfile
cat << EOF > Podfile &&
use_frameworks!
target 'QuickstartApp' do
    pod 'GoogleAPIClient/Core', '~> 1.0.2'
    pod 'GTMAppAuth'
end
EOF
pod install &&
open QuickstartApp.xcworkspace
```

ViewController.swiftは下記コードのように置き換えます。（ID等は自身のものに置き換えてください。）

```swift:ViewController.swift
import GoogleAPIClient
import AppAuth
import GTMAppAuth
import UIKit

class ViewController: UIViewController {
    
    private let kKeychainItemName = "Google Apps Script Execution API"
    private let kClientID = "YOUR_CLIENT.apps.googleusercontent.com"
    private let kScriptId = "ENTER_YOUR_SCRIPT_ID_HERE"
    
    private let kRedirectURI = "com.googleusercontent.apps.YOUR_CLIENT:/oauthredirect"
    private let kIssuer = "https://accounts.google.com"
        
    // If modifying these scopes, delete your previously saved credentials by
    // resetting the iOS simulator or uninstall the app.
    private let scopes = ["https://www.googleapis.com/auth/drive"]
    
    private let service = GTLService()
    
    var authorization: GTMAppAuthFetcherAuthorization?
    
    let output = UITextView()
    
    // When the view loads, create necessary subviews
    // and initialize the Google Apps Script Execution API service
    override func viewDidLoad() {
        super.viewDidLoad()
        
        output.frame = view.bounds
        output.isEditable = false
        output.contentInset = UIEdgeInsets(top: 20, left: 0, bottom: 20, right: 0)
        output.autoresizingMask = [.flexibleHeight, .flexibleWidth]
        
        view.addSubview(output);
        
        loadState()
        
        if authorization == nil {
            authInBrowser()
        } else {
            service.authorizer = authorization
            if let authorizer = service.authorizer,
                let canAuth = authorizer.canAuthorize, canAuth {
                callAppsScript()
            }
        }
        
    }
    
    func authInBrowser() {
        let issuer = URL(string: kIssuer)
        let redirectURI = URL(string: kRedirectURI)
        
        // discovers endpoints
        OIDAuthorizationService.discoverConfiguration(forIssuer: issuer!, completion: {
            (configuration, error) in
            if configuration == nil {
                print("Error retrieving discovery document: \(error?.localizedDescription)")
                self.setGtmAuthorization(stauthorization: nil)
                return
            }
            
            print("Got configuration: \(configuration!)")
            
            // builds authentication request
            let request: OIDAuthorizationRequest = OIDAuthorizationRequest.init(
                configuration: configuration!,
                clientId: self.kClientID,
                scopes: self.scopes,
                redirectURL: redirectURI!,
                responseType: OIDResponseTypeCode,
                additionalParameters: nil)
            
            // performs authentication request
            let appDelegate: AppDelegate = UIApplication.shared.delegate as! AppDelegate
            print("Initiating authorization request with scope: \(request.scope!)")
            
            appDelegate.currentAuthorizationFlow = OIDAuthState.authState(
                byPresenting: request,
                presenting: self,
                callback: {
                    (authState, error) in
                    if authState != nil {
                        let gauthorization: GTMAppAuthFetcherAuthorization = GTMAppAuthFetcherAuthorization(authState: authState!)
                        self.setGtmAuthorization(stauthorization: gauthorization)
                        print("Got authorization tokens. Access token: \(authState?.lastTokenResponse?.accessToken)")
                    } else {
                        self.setGtmAuthorization(stauthorization: nil)
                        print("Authorization error: \(error?.localizedDescription)")
                    }
                    if let authorizer = self.service.authorizer,
                        let canAuth = authorizer.canAuthorize, canAuth {
                        self.callAppsScript()
                    }
            })
        })
    }
    
    func saveState() {
        if authorization != nil {
            if (authorization?.canAuthorize())! {
                GTMAppAuthFetcherAuthorization.save(authorization!, toKeychainForName: "authorization")
            } else {
                GTMAppAuthFetcherAuthorization.removeFromKeychain(forName: "authorization")
            }
        } else {
            GTMAppAuthFetcherAuthorization.removeFromKeychain(forName: "authorization")
        }
    }
    
    func loadState() {
        if GTMAppAuthFetcherAuthorization(fromKeychainForName: "authorization") != nil {
            let lauthorization: GTMAppAuthFetcherAuthorization = GTMAppAuthFetcherAuthorization(fromKeychainForName: "authorization")!
            self.setGtmAuthorization(stauthorization: lauthorization)
        }
    }
    
    func setGtmAuthorization(stauthorization: GTMAppAuthFetcherAuthorization?) {
        if authorization == stauthorization {
            return
        }
        authorization = stauthorization
        stateChanged()
        service.authorizer = authorization
    }
    
    func stateChanged() {
        self.saveState()
    }
    
    // Calls an Apps Script function to list the folders in the user's
    // root Drive folder.
    func callAppsScript() {
        output.text = "Getting folders..."
        let baseUrl = "https://script.googleapis.com/v1/scripts/\(kScriptId):run"
        let url = GTLUtilities.url(with: baseUrl, queryParameters: nil)
        
        // Create an execution request object.
        let request = GTLObject()
        request.setJSONValue("getFoldersUnderRoot", forKey: "function")
        
        // Make the API request.
        service.fetchObject(byInserting: request,
                            for: url!,
                            delegate: self,
                            didFinish: #selector(displayResultWithTicket(ticket:finishedWithObject:error:)))
    }
    
    // Displays the retrieved folders returned by the Apps Script function.
    func displayResultWithTicket(ticket: GTLServiceTicket,
                                 finishedWithObject object : GTLObject,
                                 error : NSError?) {
        if let error = error {
            // The API encountered a problem before the script
            // started executing.
            showAlert(title: "The API returned the error: ",
                      message: error.localizedDescription)
            return
        }
        
        if let apiError = object.json["error"] as? [String: AnyObject] {
            // The API executed, but the script returned an error.
            
            // Extract the first (and only) set of error details and cast as
            // a Dictionary. The values of this Dictionary are the script's
            // 'errorMessage' and 'errorType', and an array of stack trace
            // elements (which also need to be cast as Dictionaries).
            let details = apiError["details"] as! [[String: AnyObject]]
            var errMessage = String(
                format:"Script error message: %@\n",
                details[0]["errorMessage"] as! String)
            
            if let stacktrace =
                details[0]["scriptStackTraceElements"] as? [[String: AnyObject]] {
                // There may not be a stacktrace if the script didn't start
                // executing.
                for trace in stacktrace {
                    let f = trace["function"] as? String ?? "Unknown"
                    let num = trace["lineNumber"] as? Int ?? -1
                    errMessage += "\t\(f): \(num)\n"
                }
            }
            
            // Set the output as the compiled error message.
            output.text = errMessage
        } else {
            // The result provided by the API needs to be cast into the
            // correct type, based upon what types the Apps Script function
            // returns. Here, the function returns an Apps Script Object with
            // String keys and values, so must be cast into a Dictionary
            // (folderSet).
            let response = object.json["response"] as! [String: AnyObject]
            let folderSet = response["result"] as! [String: AnyObject]
            if folderSet.count == 0 {
                output.text = "No folders returned!\n"
            } else {
                var folderString = "Folders under your root folder:\n"
                for (id, folder) in folderSet {
                    folderString += "\t\(folder) (\(id))\n"
                }
                output.text = folderString
            }
        }
    }
    
    // Helper for showing an alert
    func showAlert(title : String, message: String) {
        let alert = UIAlertController(
            title: title,
            message: message,
            preferredStyle: UIAlertControllerStyle.alert
        )
        let ok = UIAlertAction(
            title: "OK",
            style: UIAlertActionStyle.default,
            handler: nil
        )
        alert.addAction(ok)
        present(alert, animated: true, completion: nil)
    }
    
    override func didReceiveMemoryWarning() {
        super.didReceiveMemoryWarning()
        // Dispose of any resources that can be recreated.
    }   
}
```

認証部分が別スレッドで実行されるようになるので、callAppsScriptを実行するタイミングをviewDidAppearのタイミングから認証処理後にしています。

AppDelegate.swiftにAppAuthをimportし、AppDelegateクラスに下記のコードを追加します。

```swift:AppDelegate.swift
    var currentAuthorizationFlow: OIDAuthorizationFlowSession?
    
    func application(_ app: UIApplication, open url: URL, options: [UIApplicationOpenURLOptionsKey : Any] = [:]) -> Bool {
        if (currentAuthorizationFlow?.resumeAuthorizationFlow(with: url))! {
            currentAuthorizationFlow = nil
            return true
        }
        return false
    }
```

info.plistに下記のコードを追加します。（IDは自身のものに置き換えてください。）

```xml:info.plist
	<key>CFBundleURLTypes</key>
	<array>
		<dict>
			<key>CFBundleTypeRole</key>
			<string>Editor</string>
			<key>CFBundleURLSchemes</key>
			<array>
				<string>com.googleusercontent.apps.YOUR_CLIENT</string>
			</array>
		</dict>
	</array>
```

その他、GoogleAppsScriptは特に変更の必要はありません。