OpenID Connectで異なるドメイン間のログインセッション不整合を防ぐ方法

おはようございます, ritouです.
今日はOpenID ConnectのSession Managementについてざっくり紹介します.

まとめ

  • OpenID ConnectでOP/RP間でUserAgent上のセッションが同期される(同じユーザーがログイン状態となる)タイミングはAuthorization Responseが処理された時のみ
  • RP上で2つのiframeを表示しRP->OPへセッション確認用のpostMessageを送り続け, ユーザーがログアウトしたら”changed”を返して状態変更を把握
  • RPがログアウトした時にOPにリダイレクトさせる方法も定義されてる

これでだいたい把握できた人は, 仕様をどうぞ.
仕様 : http://openid.net/specs/openid-connect-session-1_0.html

もう少し細かく説明します.

OpenID Connectにおけるログインセッション

OpenID ConnectやOAuthのフローにおいて, UserAgent(ブラウザ)はRP->OP->RPと異なるドメインのサービスを行ったり来たりします(いわゆるOAuth Dance). 外部サービスのアカウントを認証用途で利用するOpenID Connect(や, それっぽいいわゆるOAuth as a 認証)の場合, OP/RPのログインセッションの状態は以下のようになります.

===
例1. RP/OPどちらも未ログイン状態からのOpenID Connect

RP:未ログイン, OP:未ログイン
↓   ↓   ↓   ↓   ↓
_人人人人人人人人人_
> OpenID Connect <
 ̄Y^Y^Y^Y^Y^Y^Y^Y ̄
(ユーザーAがOPにログインしてからRPに認可処理)
↓   ↓   ↓   ↓   ↓
RP:ユーザーAでログイン中, OP:ユーザーAでログイン中
===
例2. OP側でログイン状態からのOpenID Connect

RP:未ログイン, OP:ユーザーAでログイン中
↓   ↓   ↓   ↓   ↓
_人人人人人人人人人_
> OpenID Connect <
 ̄Y^Y^Y^Y^Y^Y^Y^Y ̄
(ユーザーAでログインしたままRPに認可処理)
↓   ↓   ↓   ↓   ↓
RP:ユーザーAでログイン中, OP:ユーザーAでログイン中
===
例3. 認可のときにユーザー変更したりなんかして

RP:未ログイン, OP:ユーザーAでログイン中
↓   ↓   ↓   ↓   ↓
_人人人人人人人人人_
> OpenID Connect <
 ̄Y^Y^Y^Y^Y^Y^Y^Y ̄
(OP上でユーザーBでログインし直してRPに認可処理)
↓   ↓   ↓   ↓   ↓
RP:ユーザーBでログイン中, OP:ユーザーBでログイン中
===

一般的に, 一連の処理が終わるとOP/RP上で同じユーザーがログインしている状態になります. しかし, この後OP/RPどちらかでユーザーがログアウトすると両者のログインセッションに不整合が発生します. この不整合を意識していないと, ログインしっぱなしで自分しか見れない情報を見られてしまったり意識せずに「この記事イイネ!いや, このアカウントじゃよくねーよ・・・」みたいな事案が発生するかもしれません.

この不整合を防ぐためのしくみとして, Session Managementでは次の2点を実現する方法が定義されています.

  • RPからOPに「ログインセッションにお変わりはないですか?」とちょくちょく問い合わせてOP上のログインセッションの変更を把握する
  • RPでログアウト後にユーザーをOPに送って「こっちでもログアウトする?」みたいにする

なぜOAuth 2.0にこのあたりの仕様がないのか

OAuthはあくまでAPIアクセスのためのトークンの渡し方を定義したものであり, 認証目的で用いられるかどうかなんて知ったことじゃないのです.

準備

RPはOPがSession Managementに対応しているかどうかをどうやって知るかというところですが, これはDiscoveryなりドキュメント読むなりで知ります.
Session ManagementのためにOPは次の2つのURLを用意する必要があります.

  • check_session_iframe : postMessageを送信するためにiframeで開くURL
  • end_session_endpoint : RP上でユーザーがログアウトしたときにOPに戻す先のURL

RP側もいくつか値をOPに登録しておく必要があります.

  • origin URL : postMessageの送り元の値
  • post_logout_redirect_uri : RPログアウト時にOPに戻した後にさらにRPに戻させたい場合の戻り先URL

ログインセッション確認フロー

まずはRPがOPにログインセッションの状態変化を問い合わせるフローです.

1. RPは普通にAuthorization Requestを送る

Authorization Requestに新しく追加されるパラメータとかはないです.

例:

HTTP/1.1 302 Found
Location: https://server.example.com/authorize?
response_type=code
&client_id=s6BhdRkqt3
&redirect_uri=https%3A%2F%2Fclient.example.org%2Fcb
&scope=openid%20profile
&state=af0ifjsldkj
2. OPはAuthorization Responseにsession_stateパラメータを含む

RPが「ログインセッションにお変わりないですか?」と聞くためには, 認可時にOPが現在のセッションに紐づいた値をRPに渡す必要があります.
Authorization Responseにはsession_stateというパラメータが追加されます.

例:

HTTP/1.1 302 Found
Location: https://client.example.org/cb?
code=SplxlOBeZQQYbYS6WxSbIA
&state=af0ifjsldkj
&session_state=jfwhuhoiga

RPに値を渡すということで, OP側がこのsession_stateを生成する際に気を付けないといけない点がいくつかありそうです(※ここは個人的な意見).

  • その時点のSession IDやSession Cookie文字列そのもの, もしくは簡単にそれらが取得できるような文字列はNG
  • (現在のセッション) × (client_id)に紐づいた文字列を生成する
  • 検証処理に負荷がかからないような実装にしておく(後述)

RPは受け取ったsession_stateの値を自らのセッションなりCookieで管理します.

3. RPは2つのiframeを用意し, RPからOPにpostMessageを送る

RPは2つのiframe(OP iframe/RP iframe)を用意します.このOP iframeとしてcheck_session_iframeの値を利用します.
目的はpostMessageを送るためだけなので, ユーザーにiframe内のHTMLなどを見せる必要はありません.

postMessageの中身は, client_idとsession_stateを” ”で連結した値です.
サンプルとして記載されているRP iframe側の処理を見ると, 3秒ごとにpostMessageを送っています.

  var stat = "unchanged";
  var mes = client_id + " " + session_state;

  function check_session()
  {
    var targetOrigin  = "http://server.example.com";
    var win = window.parent.document.getElementById("op").
                contentWindow;
    win.postMessage( mes, targetOrigin);
  }

  function setTimer()
  {
    check_session();
    timerID = setInterval("check_session()",3*1000);
  }

  window.addEventListener("message", receiveMessage, false);

  function receiveMessage(e)
  {
    var targetOrigin  = "http://server.example.com";
    if (e.origin !== targetOrigin ) {return;}
    stat = e.data;

    if stat == "changed" then take the actions below...
  }

このあたりの実装はRP側のポリシーによるでしょう.
短い間隔でpostMessageを送ればリアルタイムに同期している感が出ます.あとはRP側でお金周りを扱うような重要な処理の前にこのようなpostMessageを送るというのもありかもしれません.

4. OPは, postMessageを検証した後, ログイン状態に応じたレスポンスを返す

postMessageを受け取ったOPは, いくつか検証を行います.

  • client_idとsource originの組み合わせ
  • client_idとsession_stateの組み合わせ

これらが正しかった場合, ログインセッションが変更されているかどうかをRPに返します.

  • “unchanged” : 変更なし. OP上で認可時のログインセッションが継続していることを表します
  • “changed” : 変更あり. ログアウトもしくは再度同じユーザーまたは別のユーザーでログイン中, であることを表します

仕様ではOPがop_browser_stateをCookieやLocalStrageに入れておいてそれを使って確認するみたいに書いてあり, サンプルではsaltつきのSHA256とかごちゃごちゃ書いてありますが, そんなの上記の検証がきっちり行われてRPにレスポンスが返されればどんな実装でも問題ない気がします.
とはいえ, たくさんのClientから数秒おきとかにpostMessageが送られてきたりすることを考えて, なるべくバックエンドのAPIなどにアクセスを行わずCookieやLocalStrageを使って処理が完結する方がよさそうな気はします.

  window.addEventListener("message", receiveMessage, false);

  function receiveMessage(e){ // e has client_id and session_state
    // Validate message origin
    var salt;
    client_id = message.split(' ')[0];
    session_state = message.split(' ')[1];
    salt = session_state.split('.')[1];
    var opbs = get_op_browser_state();
    var ss = CryptoJS.SHA256(client_id + ' ' + e.origin + ' ' +
      opbs + [' ' + salt]) [+ "." + salt];
    if (e.session_state == ss) {
      stat = 'unchanged';
    } else {
      stat = 'changed';
    }

    e.source.postMessage(stat, e.origin);
  };
5. “changed”が返されたら, RP側はそれなりに対応する

RP側でログインセッションの不整合を検知できたら, あとは再認可するかログアウトを求めるかなど, RP側でよしなに対応すると.

RP主導のログアウト

次は, RPでログアウトした後にOP側にリダイレクトさせる方法を説明します.


1. RPはOPにユーザーをリダイレクト

送り先のURLはend_session_endpoint, パラメータは次の2つです.

  • id_token_hint : RECOMMENDED RPが保持していたID Token
  • post_logout_redirect_uri : OPTIONAL OPでのログアウト後に戻すURL

RPがログアウトさせたいユーザーを伝えるために, id_token_hintパラメータを用います.
RPとしてはログアウト漏れを回避するためにこのSession Managementを使うわけですが, OPでログアウト後, 再びRPに戻してくれという意味でpost_logout_redirect_uriの値を指定します.

2. OPはユーザーに同意を求めてログアウト

OPはRPからリクエストを受け取ったら, ユーザーに「ログアウトしますか?」のように確認し, ユーザーに同意を求めた後, ログアウトさせます. post_logout_redirect_uriがあってRPに戻す場合は, そのことについてもユーザーに説明する必要があるでしょう.

OP側はオープンリダイレクトを防ぐために, なんらかの方法でpost_logout_redirect_uriの値を検証する必要があります.
このあたりは仕様では深く書かれていない気がしますが, id_token_hintをREQUIREDにしてその中に含まれる”auc(=client_id)”の値と一致するか検証とかでもいい気がします.

3. OPからRPに戻る

post_logout_redirect_uriの値があれば, OPはログアウト後にユーザーを戻します.

終わり

この仕様, iframe/postMessageに依存していますし他のConnectの仕様とは異質な感じがありますが, 今後1つのアカウントで複数サービスにログインできる状況になるとログアウト漏れへの対策は重要になるでしょう. 導入シナリオとしては, 最初は「グループ企業のサービスでID連携してるけど実装はOAuth+αで疎結合な連携になってる」みたいなレベルのところで導入するとかがよさそうな気がします.

ではまた!