LoginSignup
12
7

More than 5 years have passed since last update.

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

Posted at

今更かもしれませんが、iOS Quickstart | Apps Script | Google DevelopersのiOS(Swift)サンプルコードがWebViewからGoogleへのOAuthリクエスト禁止に対応していないので、対応するようにしてみました。

概要

これまでiOSアプリで、GoogleドライブからGoogle Apps Scriptを使ってデータを取得するのに、Google公式のサンプルコード(iOS Quickstart | Apps Script | Google Developers)を参考にしていたのですが、このサンプルコードはGoogle認証の際にWebViewを利用する方法を取っていました。

というわけで、GoogleからアナウンスされたModernizing OAuth interactions in Native Apps for Better Usability and Security

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に案内されている通り、GTMAppAuthを利用して修正を行いました。

ただ、公開されているコードはObjective-Cだったので、Swiftでの対応ということで、未だ修正されていない(12/13現在)iOS Quickstart | Apps Script | Google DevelopersのiOS(Swift)のサンプルコードを対応させてみました。

コード

手順は基本的にiOS Quickstart | Apps Script | Google Developersの通りです。

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

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等は自身のものに置き換えてください。)

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クラスに下記のコードを追加します。

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は自身のものに置き換えてください。)

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は特に変更の必要はありません。

12
7
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
12
7