Google+ Sign-Inの認可フローを調べた

こんにちは、ritouです。
最近、Google+プラットフォームに新しい機能が追加されたようですね。

今までもシェアボタンとか、開発者限定のHistory APIとかがあったわけですが、外部サービスとの連携に必要な機能を揃えて正式に公開したという感じでしょうか。

たくさんある機能の概要紹介や、これでSNSの勢力図があーなってこうなってそういえばmixiどーなのよみたいな話は他の人にお任せします。

今回はWebサービス(ブラウザ?)からの認可処理にスコープを絞り、G+ Sign inというボタンを押すと何が行われるかを調べました。
まずはフローから見ていきます。ドキュメントを見ると、Client-sideとServer-sideの2種類の実装が提供されています。

Client-side flow

JavaScriptだけでなんとかするパターンです。

下記ドキュメントに一連の手順が書いてあります。
https://developers.google.com/+/web/signin/#using_the_client-side_flow

  1. Client ID/Client Secretの取得(アプリケーションの登録)
  2. G+のJavaScriptをWebサイトに導入
  3. G+ Sign in ボタンを生成
  4. callbackの実装

実際に動くものを見てみましょう。
http://www8322u.sakura.ne.jp/GoogleOAuth2Sample/client-side2.html

G+ Sign inボタンがあって

押すとポップアップ立ち上がってログイン中なので同意画面が出ますよ。

同意するとポップアップを閉じてID Tokenやらなんやらが表示されます。

この流れを見るとGoogleJavaScriptがポップアップ立ち上げてOpenID ConnectのImplicit Grantの処理をうまくやってポップアップ閉じてると考えるのが普通でしょう。しかし、細かいところを見ていくと少し違います。認可要求と応答を見てみます。

Authorization Request

ポップアップで開かれたAuthorization RequestのURLを確認します。

https://accounts.google.com/o/oauth2/auth?
client_id=26633205769-n4f7hl7sgeeanj7fl71lusci7ml0b50d.apps.googleusercontent.com&
redirect_uri=postmessage&
response_type=code%20token%20id_token%20gsession&
scope=https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fplus.login%20https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fuserinfo.email&
approval_prompt=force&
request_visible_actions=http%3A%2F%2Fschemas.google.com%2FCommentActivity&
cookie_policy=single_host_origin&
proxy=oauth2relay523845775&
origin=http%3A%2F%2Fwww8322u.sakura.ne.jp&
state=759081621%7C0.256375737&
authuser=0

なんか余計なのがいっぱいついていますが、心の目で見るとOpenID Connect周りのパラメータが以下のようになっているのがわかります。

  • response_type : code token id_token(OpenID Connect仕様でいうところのトッピング全部乗せ!) + gsession(独自)
  • redirect_uri : postmessage(独自)
  • stateは自動で設定

Googleがredirect_uriのクエリやfragment identifierを使わずにpostMessageでやりとりするJavaScriptを提供しているのは以前から知っていました。
cookie_policy, proxy, originなどはこのやりとりに必要なものでしょう。
細かいパラメータも実に興味深いのですが、レスポンスを見てみます。

Authorization Response

ユーザーが同意をしたあとにはリダイレクトではなく、次のようなHTMLが表示されます。
FormからpostMessageで結果を渡しているのでしょう。

<html>
<head><title>接続しています...</title>
<meta http-equiv="content-type" content="text/html; charset=utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<script type="text/javascript" src="https://oauth.googleusercontent.com/gadgets/js/core:rpc:shindig.random:shindig.sha1.js?c=2"></script>
<script type="text/javascript" src="https://ssl.gstatic.com/accounts/o/222820762-postmessage.js"></script>
</head>
<body onLoad="postmessage.onLoad();">
<form action="javascript:">
<input type="hidden" id="error" value="false" />
<input type="hidden" id="response-form-encoded" value="state=759081621%7C0.256375737&amp;access_token=(Access Token)&amp;token_type=Bearer&amp;expires_in=3600&amp;code=(Authorization Code)&amp;id_token=(ID Token)&amp;authuser=0&amp;prompt=consent&amp;session_state=f99bac6638ce0c0ec62e211e1799139eb4d475ea..5f43" />
<input type="hidden" id="origin" value="http://www8322u.sakura.ne.jp" />
<input type="hidden" id="proxy" value="oauth2relay523845775" />
<input type="hidden" id="relay-endpoint" value="https://accounts.google.com/o/oauth2/postmessageRelay" />
<input type="hidden" id="after-redirect" value="" />
</form>
</body>
</html>

最終的に、ボタンを設置したHTMLのcallbackにcode, access_token, id_tokenなどのパラメータが渡されます。ID Tokenの中身をdumpしてみました。

# Header
{"alg":"RS256",
 "kid":"45a990ccfa4046e8b4f52bafa757b643d3b70fe0"}
# Payload
{"iss":"accounts.google.com",
 "aud":"26633205769-n4f7hl7sgeeanj7fl71lusci7ml0b50d.apps.googleusercontent.com",
 "at_hash":"(Access Tokenのハッシュ値をごにょごにょした値)",
 "email_verified":"true",
 "c_hash":"(Authorization Codeのハッシュ値をごにょごにょした値)",
 "email":"(メールアドレス)",
 "sub":"(user id)",
 "azp":"26633205769-n4f7hl7sgeeanj7fl71lusci7ml0b50d.apps.googleusercontent.com",
 "iat":1362155061,
 "exp":1362158961}

このID Tokenのフォーマットは現状の最新仕様に沿っているように見えます。

署名生成のアルゴリズムRSA SHA-256, 公開鍵は以下のURLの中のkidで指定されているものになります。
https://www.googleapis.com/oauth2/v1/certs
このURLや公開鍵を取得して検証する方法はGoogleが提供している各種ライブラリに含まれて入るものの、ドキュメントでは公開されていないと認識しています。
Payloadにat_hash, c_hashという値が含まれているので、通常のImplicit GrantであればこのID Tokenの値とつき合わせてパラメータのセットが改ざんされていないことが検証できますし、しなければなりません。
しかし、JavaScriptの中でやっているかどうかは不明です。postMessageを利用しているのはこの検証を省略できるという意味合いもあるのでしょうか?このあたりはお酒のつまみにしたいところです。

もう一点、気になるのがoriginまわりです。
G+ sign-inボタンを生成するときにCookieやoriginに関する指定が可能です。
https://developers.google.com/+/web/signin/#determining_a_value_for_cookie_policy
さすがに別ドメインなどは使えませんが、originベースの制限となるためにredirect_uriよりも制限が緩くなっている気もします。

最近話題になったDMスパムの件でもわかるとおり、OAuthでリダイレクト先が緩く指定できることと他の要素が組み合わされることで様々なリスクにつながりかねません。

次のようなケースで認証結果が意図せず共有されてしまうような気がします。

  • 同一ドメイン内で複数のサービスが動いていて、あるサービス(/user1/app.html)の認証結果を別のサービス(/user2/app.html)から参照できたりしないか

サブドメインあたりもちょっと匂いますが、このあたり詳しい人がいれば調べていただけたらなと思います。

APIアクセスについては受け取ったAccess Tokenをうまく使ってやる感じになります。
ここは問題なさそうなので省略します。

Client-side flowまとめ
  • Implicitではなく、全部乗せ+postMessage
  • ID Tokenのフォーマットを見る限り最新のOpenID Connectの仕様をサポート

ここまでのサンプルはここにおいておきました。
https://gist.github.com/ritou/5066101

このサンプルのボタンのタグのとことにscopeを追加してみたり、Activity周りの指定であるrequestvisibleactionsの値をいじってどうなるか確かめてみるのも良いかと思います。data-approvalpromptの指定を外すと同意画面をスキップします。

同意画面の自由度

Server sideの説明の前に、同意画面について少しまとめておきます。
Google+ Sign-Inの同意画面には次のような特徴があります。

  • プロフィール情報と一緒に渡される「Google+ で交流しているユーザーのリスト」をclient単位で指定可能
  • Google+に送られる「ユーザーがclient上で○○した」という情報を共有する範囲をclient単位で指定可能

このあたりはFacebookがExtended Promissionsというユーザーが無効化可能なscopeを実装していますが、Googleの場合はサークルを共有範囲の指定に利用しています。
お互いに実装内容はことなるものの、clientからの要求に対してユーザーの意見を反映できることは重要だと思います。
このあたりのノウハウがたまっていってユーザー主導でポリシーが決められるようになると良いと思います。

Server-side flow

サーバーサイドは下記ドキュメントに一連の手順が書いてあります。
https://developers.google.com/+/web/signin/server-side-flow

手順は以下のようになります。

  1. Client ID/Client Secretの取得(アプリケーションの登録)
  2. バックエンドのサーバー側でstateを生成してフロントエンドで指定 ★
  3. G+のJavaScriptをWebサイトに導入
  4. G+ Sign in ボタンを生成
  5. callbackからバックエンドのサーバーにAuthorization Codeを送信 ★
  6. バックエンドのサーバーはCSRF対策の確認をした後にAccess Token取得 ★

通常のAuthorization Code Grantを使うわけではなく、Client-side flowと同じ方法で取得したAuthorization Codeをバックエンドにおくり、バックエンドがAccess Tokenなどを取得してAPIアクセスする流れになっています。
サーバサイドで作成したstateパラメータはHTML上のJavaScriptからAuthorization Codeを受け取る際のCSRF対策として利用されるようです。

https://developers.google.com/+/images/server_side_code_flow.png
(引用:https://developers.google.com/+/web/signin/server-side-flow)

個人的に思ったのは、このデータの流れはモバイルアプリ - バックエンドサーバーの流れに似ている気がします。モバイルアプリまでCode/Access Token/ID Tokenを渡す。これはSDKを使うことで比較的安全にできるでしょう(そうでもない?)。その後に必要ならばバックエンドのサーバーにAuthoriation Codeを送って連携させると。モバイルアプリとOAuth 2.0の実装については考えなければならないことが多いわけですが、今回のフローがGoogleとしての考えなのかなとも思ったりします。

このサンプルはGoogleのOAuth 2.0用のクライアントライブラリを用いた説明になっていますが、PureなOAuth/OpenID Connectとの差があると開発者の実装コストが高まってしまうのではと感じます。

とりあえず動かしました。
http://www8322u.sakura.ne.jp/GoogleOAuth2Sample/server-side2.html
バックエンドにAJAXでAuthorization Codeを送っています。
Authorization Requestのredirect_uriがpostmessageだったので、サーバー側でAccess Tokenを取得するときもその値を使ったら動きました。

しかし、ドキュメントだけでは下記の2点が不明だったのでブラウザ-バックエンドのサーバー間のstateを実装しておらず、あまり良いサンプルではありません。

  • サーバーサイドで作成したstateパラメータをどのようにして認可要求にのせるのか : G+ sign-inボタンで指定?
  • サーバサイドはどうやってcallbackからstateの値を受け取るのか

Googleはこのあたりもう少し書いても良いと思います。

Server-side flowまとめ
  • Authorization Codeではなく、Client-side(全部乗せ+postMessage) + バックエンドへAJAX使ったAuthorization Code渡しを推奨?
  • Access Token取得時のredirect_uriはpostmessageを指定すると動く

今日はここまでにします。
ではまた!