Android からGoogleアカウント認証でGAEアクセスするとServer Error になってしまう

  1. Android (実機) と GAE を連携させるためのデバッグ環境を Windows7 に構築する
  2. Android アプリから Google アカウントを利用して GAE アプリケーションを利用する
  3. GAE開発サーバーアカウントを利用する
  4. Android GAE を利用したアプリを開発するのに Google App Engine Launcher が便利

と徐々に Android と GAE を連携させるべく調査、テストを進めてきたのだが、当初問題なく動いていたテストアプリが、突然エラーになるようになってしまった。

アプリから、送信しているURL

http://typea-android-apps.appspot.com/_ah/login?continue={認証後に遷移するパス}&auth={認証トークン}

を、ブラウザから投げてみると、

gae_auth01

The server encountered an error and could not complete your request.

If the problem persists, please report your problem and mention this error message and the query that caused it.

てな、エラーが発生している。

しばらく、トライ&エラーを繰り返すも解決せず・・・

うーむ。

上記、メッセージで検索してみると、StackOverflow に以下の情報が

http://stackoverflow.com/questions/3919071/what-is-the-proper-url-to-get-an-auth-cookie-from-a-gae-based-application

どうも、認証が期限切れなのが原因のようですね。

それにしても StackOverflow にはいつも助けられる。ありがとうございます。

GAE 側の Application Setting から、Cookie Expiration を確認すると、1Day になっているので、最初のテストの時には、OKだが、数日たって再テストすると、期限切れになっていたのだろう。

gae_auth02

 

ということで、API リファレンスを確認すると、、、

public void invalidateAuthToken (String accountType, String authToken)

・認証トークンをAccountManagerのキャッシュから削除します。
・認証トークンが、キャッシュにない場合何もしません。
・アプリケーションは、期限が切れた、もしくは、認証リクエストが無効になる、認証トークンが見つかった場合、このメソッドを呼ぶべき。
・AccountManager  は、キャッシュされた認証トークンを、有効化したり期限切れにしたり
等はしない。
・このメソッドはメインスレッドから呼んでも安全。
・このメソッドは、呼び出し元に MANAGE_ACCOUNTS もしくは USE_CREDENTIALS パーミッ
ションを要求

パラメータ

accountType     認証トークンの、アカウントタイプ。null不可
authToken       無効にする認証トークン、null の場合あり

てな感じのことが書いてあるように自分には読めた。

今回のエラーでは、ステータスコード 500が帰ってきているので、とりあえず、ステータスコードを確認して、500なら、キャッシュを削除して、再度認証トークンを取得するようにすれば良さそうだ。

そのようにコードを書き直してみる。(500番台のエラーとしてもいいのかも)

 

実際、肝心な所は、removeAuthTokenCache() だけなのだが、「Android アプリから Google アカウントを利用して GAE アプリケーションを利用する 」のままでは、あまりに汎用性がない(まぁ動作確認が目的なので致し方ないが)ので、ある程度使い回せることを目指して、コードを書き直す。

package info.typea.lib;

import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;

import org.apache.http.HttpResponse;
import org.apache.http.HttpStatus;
import org.apache.http.client.ClientProtocolException;
import org.apache.http.client.methods.HttpGet;
import org.apache.http.client.methods.HttpPost;
import org.apache.http.client.params.ClientPNames;
import org.apache.http.cookie.Cookie;
import org.apache.http.impl.client.DefaultHttpClient;

import android.accounts.Account;
import android.accounts.AccountManager;
import android.accounts.AccountManagerCallback;
import android.accounts.AccountManagerFuture;
import android.accounts.AuthenticatorException;
import android.accounts.OperationCanceledException;
import android.content.Context;
import android.content.Intent;
import android.os.Bundle;
import android.util.Log;

/**
 * @author piroto
 */
public class GoogleServiceUtil {
    
    private GoogleServiceUtil() {}
    
    public enum GOOGLE_ACCOUNT_TYPE {
        GAE         { public String toString() {return "ah";         } },
        CALENDAR    { public String toString() {return "cl";         } },
        GMAIL       { public String toString() {return "mail";       } },
        READER      { public String toString() {return "reader";     } },
        TALK        { public String toString() {return "talk";       } },
        YOUTUBE     { public String toString() {return "youtube";    } },
    }
    
    /**
     * GAE での認証用URIを生成
     * @param appPath
     * @param authToken
     * @return
     */
    public static String defaultGAELoginUrl(String hostname, String appPath, String authToken) {
        return "http://" + hostname + "/_ah/login?continue=" + appPath + "&auth=" + authToken;
    }
    
    /**
     * Google のサービスを実行するクラス
     * 
     * @author piroto
     */
    public static class Executer {
        public static final String GOOGLE_ACCOUNT_TYPE = "com.google";
        
        private Context context;
        private AccountManager accountManager;
    
        /**
         * @param context
         */
        public Executer(Context context) {
            this.context = context;
        }
        
        /**
         * @return
         */
        private AccountManager getAccountManager() {
            if (accountManager == null) {
                accountManager = AccountManager.get(this.context);
            }
            return accountManager;
        }
        
        /**
         * Google アカウントを取得
         * @return
         */
        public Account[] getGoogleAccounts() {
            return getAccountManager().getAccountsByType(GOOGLE_ACCOUNT_TYPE);
        }
        
        /**
         * 認証を行った後、Google サービスを実行させる
         * @param account Google アカウント
         * @param type アカウントタイプ
         * @param callback 認証の要求が完了したときに呼び出されるコールバック
         */
        public void execute(Account account, GOOGLE_ACCOUNT_TYPE type, AbstractGoogleServiceCallback callback) {
            
            getAccountManager().getAuthToken(
                        account, 
                        type.toString(), 
                        false, 
                        callback, 
                        null        // nullの場合、callback はメインスレッド
                        );
        }
        
        /**
         * アカウントを削除
         * @param account
         * @param type
         */
        public void removeAccount(Account account) {
            getAccountManager().removeAccount(account, null, null);
        }
    }
    
    /** 
     * Google サービスの認証部を実装したコールバック
     * @author piroto
     *
     */
    public static abstract class AbstractGoogleServiceCallback implements AccountManagerCallback {
        private Context context;

        /**
         * @param context
         */
        public AbstractGoogleServiceCallback(Context context) {
            this.context = context;
        }
        
        /**
         * @return
         */
        public Context getContext() {
            return this.context;
        }
    
        /**
         * @param bundle
         * @param authToken
         */
        public void removeAuthTokenCache(Bundle bundle, String authToken) {
            String accountType = bundle.getString(AccountManager.KEY_ACCOUNT_TYPE);
            AccountManager manager = AccountManager.get(getContext());
            manager.invalidateAuthToken(accountType, authToken);
        }
        
        /**
         * ACSID を取得する
         * @param bundle
         * @return
         */
        private String getACSID(Bundle bundle){
            final int RETRY_MAX = 3;
            boolean isValidToken = false;
            
            int    retry = 0;
            String acsid = null;
            try {
                DefaultHttpClient httpClient = null;
                while (!isValidToken) {

                    String authToken = bundle.getString(AccountManager.KEY_AUTHTOKEN);
                    httpClient = new DefaultHttpClient();
                    httpClient.getParams().setBooleanParameter(ClientPNames.HANDLE_REDIRECTS, false);

                    String uri = getAuthenticateUri(authToken);
                    HttpGet httpAuthGetRequest = new HttpGet(uri);
                    HttpResponse httpAuthResponse = httpClient.execute(httpAuthGetRequest);
                    
                    int status = httpAuthResponse.getStatusLine().getStatusCode();
                    if (status == HttpStatus.SC_INTERNAL_SERVER_ERROR) {

                        // 認証エラーの場合、ログにレスポンスの内容を出力
                        try {
                            StringBuilder buf = new StringBuilder();
                            buf.append(String.format("status:%d\n",status));
                            InputStream in = httpAuthResponse.getEntity().getContent();
                            BufferedReader reader = new BufferedReader(new InputStreamReader(in));
                            String l = null;
                            while((l = reader.readLine()) != null) {
                                buf.append(l + "\n");
                            }            
                        }catch(Exception e) {
                        }
                        
                        // 認証トークンキャッシュの削除
                        //   期限切れ、もしくは、認証リクエストが無効になるような、認証トークンが見つかった場合、
                        //   キャッシュのクリアを行う
                        removeAuthTokenCache(bundle, authToken);

                    } else {
                        isValidToken = true;
                    }
                    retry++;
                    if (retry > RETRY_MAX) {
                        break;
                    }
                }
                if (isValidToken) {
                    // 認証エラーでなければ、Cookie から ACSIDを取得する
                    for (Cookie cookie : httpClient.getCookieStore().getCookies()) {
                        if ("SACSID".equals(cookie.getName()) ||
                            "ACSID".equals(cookie.getName())) {
                            acsid = cookie.getName() + "=" + cookie.getValue();
                        }
                    }                    
                }
            } catch (ClientProtocolException e) {
                // TODO Auto-generated catch block
                e.printStackTrace();
            } catch (IOException e) {
                // TODO Auto-generated catch block
                e.printStackTrace();
            }
            return acsid;
        }
        
        @Override
        public void run(AccountManagerFuture future) {

            HttpResponse httpBodyResponse = null;
            try {
                Bundle bundle = future.getResult();
                Intent intent = (Intent)bundle.get(AccountManager.KEY_INTENT);
                if (intent != null) {
                    // 認証されていない場合、認証画面を起動
                    this.context.startActivity(intent);
                } else {

                    DefaultHttpClient httpClient = new DefaultHttpClient();
                    String acsid = getACSID(bundle);
                    // 認証が正常に行われ、ACSIDが取得できたら、サービスのリクエストをPOSTし、
                    // レスポンスを取得
                    if (acsid != null) {
                        HttpPost httpPost = request();
                        httpPost.setHeader("Cookie", acsid);
                        httpBodyResponse = httpClient.execute(httpPost);
                    } 
                }
                    
            } catch (OperationCanceledException e) {
                // TODO
                e.printStackTrace();
            } catch (AuthenticatorException e) {
                // TODO
                e.printStackTrace();
            } catch (IOException e) {
                // TODO
                e.printStackTrace();
            } finally {
                // コールバックする。認証などでエラーが発生している場合、レスポンスは null
                callback(httpBodyResponse);
            }
        }

        /**
         * 認証を行うURLを指定
         * たとえばGAEであれば、以下のようなURLを返す
         * 
"http://typea-msg-brd.appspot.com/_ah/login?continue=/&auth=" + authToken;
* @param authToken * @return */ public abstract String getAuthenticateUri(String authToken); /** * 認証に成功した後、実際に行いたいリクエストを実装する * @return */ public abstract HttpPost request(); /** * request() レスポンスを受け取る * @param response */ public abstract void callback(HttpResponse response); } }

呼び出し側は、例えば、以下のように使う。

GoogleServiceUtil.Executer exec = new GoogleServiceUtil.Executer(this);

for (Account a : exec.getGoogleAccounts()) {
    exec.execute(a, GOOGLE_ACCOUNT_TYPE.GAE, new GoogleServiceUtil.AbstractGoogleServiceCallback(this) {
        @Override
        public HttpPost request() {
            // HTTP POST リクエストオブジェクトを生成して返す
            HttpPost post =  new HttpPost("http://typea-android-apps.appspot.com/lbmsg/insert");
            try {
                List parms = new ArrayList();
                parms.add(new BasicNameValuePair("lat", String.valueOf(loc.getLatitude())));
                parms.add(new BasicNameValuePair("lon", String.valueOf(loc.getLongitude())));
                parms.add(new BasicNameValuePair("message", msg));
                post.setEntity(new UrlEncodedFormEntity(parms, HTTP.UTF_8));
            } catch (UnsupportedEncodingException e) {
                // TODO Auto-generated catch block
                e.printStackTrace();
            }
            return post;
        }
        
        @Override
        public String getAuthenticateUri(String authToken) {
            // 認証を行うURLを返す
            return GoogleServiceUtil.defaultGAELoginUrl("typea-android-apps.appspot.com",
                                                        "/lbmsg",
                                                        authToken);
        }
        
        @Override
        public void callback(HttpResponse response) {
            // サービスを呼び出したレスポンスに対する処理
            if (response != null) {
                int status = response.getStatusLine().getStatusCode();
                
                StringBuilder buf = new StringBuilder();
                buf.append(String.format("status:%d", status));
                try {
                    InputStream in = response.getEntity().getContent();
                    BufferedReader reader = new BufferedReader(new InputStreamReader(in));
                    String l = null;
                    while((l = reader.readLine()) != null) {
                        buf.append(l + "\r\n");
                    }        
                    if (status != HttpStatus.SC_OK) {
                        Log.e(LocationBasedMessageApplication.TAG, buf.toString());
                    }
                    
                } catch(Exception e) {
                    e.printStackTrace();
                }                    
                (Toast.makeText(getContext(), buf.toString(), Toast.LENGTH_LONG)).show();
            }
        }
    });
    
    break;
}

結果、Server Error は解決されましたとさ。めでたしめでたし。