AndroidでGoogle OAuth2認証を行う。(Installed Applicationとして登録)
仕事でやってるアプリがGoogle Appsと連携するのだけど、OAuth2認証をandroidのAccountManagerに管理されたくないので、Androidでブラウザ使って認証するための試行錯誤
なんでAccountManagerで認証されたくないかというとタブレットで動かしているのだけど、アプリがKIOSK端末アプリ的な位置づけのため、ブラウザ開くとすでにログインしているとかシャレにならない。(gmail開くとメール見れるとか困るんですよ。まったく)
認証が面倒くさいというよりセキュリティ面での信頼がおける方法を実装するのが面倒くさいというほうが正しいとおもう。
(参考) http://awwa500.blogspot.jp/2012/12/androidgoogle-service-oauth20.html
最初はブラウザではなくWebView使ったアプリ内ブラウザでやっていたのだけど(セキュリティ的には問題あるけどやっぱり楽)、Google AppsでのSSO対応の検証でオレオレ証明書対応やBasic認証時のダイアログとかが必要になって、そこまでやる必要あるならブラウザ対応したほうが良いかもというのが検証の発端。
実際のところはSAML連携を行う製品群、普通にHTMLフォーム認証ならばよいのだけれど、ADFS連携というのが苦しくて、認証時の方法がwindows認証(kerberos)、basic認証の順になっていた。
WebViewでフォームBasic認証するのは実装すればできるのだけど、先のオレオレ証明書とか、kerberos認証考えるとブラウザと連携したくなった。
https://developers.google.com/accounts/docs/OAuth2InstalledApp#choosingredirecturi
面倒な点としては
- Installed Applicationだとhttp://localhostかurn:ietf:wg:oauth:2.0:oobを使用するという点で今回はブラウザつかうためにhttp://localhostに自動的になるんだけど、先に上げたページの記述をみるとこう書いてある。
これを何とかしようとすると、Android端末上にWebサーバが必要になるが、ポート80を一般のアプリケーションで待ち受けるのは制限されているためフレームワークに手を入れない限り不可能。というわけで、この方法もアウト。
なにがでるか: AndroidからGoogle Service OAuth2.0の利用で詰んでいる
この記述はちょっと間違っていて実はポートは何を指定しても良い。
GoogleのドキュメントでもAPIコンソールでの変更なしにポートを変更できると書いてある
This value signals to the Google Authorization Server that the authorization code should be returned as a query string parameter to the web server on the client. You may specify a port number without changing the APIs Console configuration.
Using OAuth 2.0 for Installed Applications - Google Accounts Authentication and Authorization — Google Developers
実際、他のアプリがポートを使用していることもあるので、ポート番号は動的に決めてやらないといけない。実装方法としてはServerSocketはコンストラクタでポートを0で指定すると空いてるポートにbindしてくれてServerSocket#getLocalPortでそのポートを取得できる。なので、認証時のみサーバーを起動して、そのときのポートからコールバックURLをつくればよいということになる。
次の問題はHTTPサーバーどうするって話だけど、よのなかにはNanoHTTPDっていうのがあって、1ファイルでWebサーバー起動の為のクラスが提供されてる。Androidで動かしているひともたくさんいるので参考にすればよい。
気をつける点は起動後にポートを取得できるようにすることと認証時のみの短期間だけの起動なので認証終わったらちゃんと落とすこと。ブラウザはfaviconとかよみにくるので、安易に全部同じレスポンス返さないこと。タイミングによっては受けつけたSocketの途中でサーバーソケット切れてたりするので気をつける。NanoHTTPDは処理がぬるい部分もあるので自前で実装するほうがよいかもしれないし、Jetty,nettyで組み込みサーバーつくるほうがよいかもしれない。
さて次にコードを取得したあとにどうやってアプリを前面にもってくるか?Activityに渡すか?
2つ方法があってWebサーバーからActivityに対してstartActivityする方法とウェブサーバーからリダイレクトで独自スキーマで起動できるようにしておくことが思いつく。
今回の検証ではリダイレクト時にcodeの値も含めてリダイレクトしてonNewIntentでURIパースすることにした。
ここまでやると実はInstalled ApplicationではなくWeb Server立ちあげておいて、Web Serverのフローで独自スキーマにリダイレクトすればいいというのも思いつく。
実際Android上でWebサーバー立ちあげてActivityと連携することかんがえると、Webサーバーでやるほうがずっといい気がする。
全部じゃないけど、検証コード
/** * start OAuth2 process . * */ public void requestAuthorize() { Log.v("OAuth2", "Start OAuth2 Flow"); try { // Webサーバー起動 server = new NanoHTTPD(0, null){ @Override public Response serve(String uri, String method, Properties header, Properties parms, Properties files) { NanoHTTPD.Response response = null; // 指定したコールバックのパスの時に独自スキーマにリダイレクト if(uri.equals("/oauth2callback")){ response = new NanoHTTPD.Response(NanoHTTPD.HTTP_REDIRECT, "text/plain", "redirect to myapp"); Uri.Builder builder = new Uri.Builder(); builder.scheme("myapp").authority("").path("/"); // OAuth2の認可コードをURLから抽出して、リダイレクトするURLに含める for(String name : parms.stringPropertyNames()){ builder.appendQueryParameter(name, parms.getProperty(name)); } String launch = builder.build().toString(); response.addHeader("Location",launch); Log.d("HTTP-Server", launch ); }else{ response = new NanoHTTPD.Response(NanoHTTPD.HTTP_NOTFOUND, "text/plain", "Not Found "); } return response; } }; // インスタンス生成時にポートを0で指定したので実際のポートを取得。 // NanoHTTPDにはこの処理はないので追加してある。 int port = server.getLocalPort(); // コールバックURLをポート含めて生成 String callback = "http://localhost:" + port + "/oauth2callback"; Log.d("HTTP-Server", callback); generatedCallback = callback; String authUrl = authCodeFlow.newAuthorizationUrl().setRedirectUri(callback).build(); Uri authUri = Uri.parse(authUrl); // Intent経由でブラウザ起動 Intent intent = new Intent(Intent.ACTION_VIEW, authUri); startActivity(intent); } catch (IOException e) { // TODO Auto-generated catch block e.printStackTrace(); } } NanoHTTPD server = null; String generatedCallback = null; @Override protected void onNewIntent(Intent intent) { // Webサーバーからカスタムスキーマで起動される // myapp:///?code=xxxxxx AsyncTask.execute(new Runnable(){ @Override public void run() { // サーバー停止 try { if(server != null){ server.stop(); server = null; } }catch(Exception e){ Log.d("HTTP-server", "can not stop", e); } } }); // intent.action.VIEWかどうかを判定 if(isActionView(intent)){ Uri uri = intent.getData(); Log.d("OAuth2", uri.toString()); if(!hasAuthorizationError(uri)){ String state = uri.getQueryParameter("state"); final String code = uri.getQueryParameter("code"); AsyncTask.execute(new Runnable(){ @Override public void run() { try { // codeをAccessTokenに交換 GoogleAuthorizationCodeTokenRequest codeTokenReq = authCodeFlow.newTokenRequest(code).setRedirectUri(generatedCallback); // Credentialを保存 authCodeFlow.createAndStoreCredential(codeTokenReq.execute(), GOOGLE_USER); // calendarサービスを初期化しておく if(existsAuthorizedCredential()){ Log.d("OAuth2", credential.getAccessToken()); initializeCalendarService(); Log.d("OAuth2", calendarService.toString()); } } catch (IOException e) { } } }); }else { String error = uri.getQueryParameter("error"); Toast.makeText(this, error, Toast.LENGTH_LONG).show(); } } }
http://localhostへのコールバックをインテントフィルターで受け取るというのもあるのだとおもうけど、Chromeだとうまくうけとれなかった。今後Chromeが標準ブラウザになるんだとするとまあ厳しい。