今更かもしれませんが、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の中身が変わります。
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等は自身のものに置き換えてください。)
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クラスに下記のコードを追加します。
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は自身のものに置き換えてください。)
<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は特に変更の必要はありません。