2016.07.05

OpenAMによるOpenID Connect


こんにちは。次世代システム研究室のM.Mです。

現在、IDサービス、SSOに関連する開発案件に携わっており、OpenAMにてOpenID Connectによる認証・認可の仕組みを提供するIDサービスを検討しております。
OpenAMによるOpenID ConnectについてはGoogleなどで検索するとたくさん記事がでてきますが、実際に使ってみると苦労する箇所がいくつかでてきました。
今回はその苦労したポイントを紹介したいと思います。

OpenAMについてはこちら
以下の説明ではhttp://openam.example.com:8080/openamにてOpenAMにアクセスできるようにしたケースになります。
尚、今回利用しているOpenAMのバージョンは13.0.0になります。

目次

  1. OpenAMによるOpenID Connectの設定方法
  2. OpenID Connect利用の流れ
  3. 何に苦労したのか?

OpenAMによるOpenID Connectの設定方法


まずはOpenID Connectのプロバイダーの設定

OpenAMの管理コンソールにアクセスし下の図のように[Top Level Realm] -> [Configure OAuth Provider] -> [Configure OpenID Connect]と選択してOpenID Connect プロバイダーとしての設定を行います。

登録が完了したら以下のURLにてOpenID Connectのエンドポイントが確認できます。
http://openam.example.com:8080/openam/oauth2/.well-known/openid-configuration

■出力結果
{"response_types_supported":["code token id_token","code","code id_token","id_token","code token","token","token id_token"],
  "claims_parameter_supported":false,
  "end_session_endpoint":"http://openam.example.com:8080/openam/oauth2/connect/endSession",
  "version":"3.0",
  "check_session_iframe":"http://openam.example.com:8080/openam/oauth2/connect/checkSession",
  "scopes_supported":["address","phone","openid","profile","email"],
  "issuer":"http://openam.example.com:8080/openam/oauth2",
  "acr_values_supported":[],
  "authorization_endpoint":"http://openam.example.com:8080/openam/oauth2/authorize",
  "userinfo_endpoint":"http://openam.example.com:8080/openam/oauth2/userinfo",
  "claims_supported":["zoneinfo","address","profile","name","phone_number","given_name","locale","family_name","email"],
  "jwks_uri":"http://openam.example.com:8080/openam/oauth2/connect/jwk_uri",
  "subject_types_supported":["public"],
  "id_token_signing_alg_values_supported":["HS256","HS512","RS256","HS384"],
  "registration_endpoint":"http://openam.example.com:8080/openam/oauth2/connect/register",
  "token_endpoint_auth_methods_supported":["client_secret_post","private_key_jwt","client_secret_basic"],
  "token_endpoint":"http://openam.example.com:8080/openam/oauth2/access_token"}
認証時に利用するエンドポイント(authorization_endpoint)が http://openam.example.com:8080/openam/oauth2/authorizeであるなどが分かります。

OpenID Connectを利用するクライアントの設定

[Top Level Realm] -> [Agents] -> [OAuth 2.0 クライアント] -> [新規]と選択しOpenID Connectを利用するクライアントの登録をします。
今回は以下のように登録しました。(これらの値はOpenID Connectを使う際に必要になります。)
クライアント名: testservice1
パスワード: 適当
リダイレクトURL: http://testservice1.example.com/cb
スコープ: openid, email

これでクライアントtestservice1がOpenID Connectを利用するための設定は完了です。

OpenID Connect利用の流れ


1. 認証エンドポイントにリダイレクトさせる

■PHPサンプルコード
header('Location: '.
   'http://openam.example.com:8080/openam/oauth2/authorize'.
   '?response_type=code'.
   '&client_id=testservice1'.
   '&redirect_uri='.urlencode('http://testservice1.example.com/cb').
   '&scope=openid%20email'.
   '&display=page'.
   '&nonce=abcdef'.
   '&state=123456');
OpenID ConnectはCode Flow、Implicit Flow、Hybrid Flowという認証フローがあります。
(Code Flowは一般的にサーバーサイドアプリで使われるフローなので今回はCode Flowを選んでいます。)
3行目のresponse_typeにcodeを指定します。
4行目のclient_idにはクライアントとして登録したクライアント名を指定します。
5行目のredirect_uriにはクライアントとして登録したリダイレクトURLを指定します。(登録した内容と完全一致していなければなりません。)
6行目のscopeにはクライアントとして登録したスコープを指定します。
7行目のdisplayにはpage, popup, touch, wapを指定でき、pageはPC用の画面、wapは携帯用の画面といったように認可画面を出し分けることができます。
8行目のnonceはリプレイアタック対策として使います。Implicit Flowの場合は必須です。(今回は適当な値にして対策は省略しています。)
9行目のstateはCSRF対策として使います。(今回は適当な値にして対策は省略しています。)

2. リダイレクト後、未ログインであれば以下のようなログイン画面が表示される


3. ログインするとtestservice1がユーザー情報(今回はメールアドレス)を利用することを許可するか確認する画面が表示される


4. 許可するとリダイレクトURLとして指定したURLにcodeパラメータをつけてリダイレクトされる

このcodeを使ってユーザー情報にアクセスします。
■PHPサンプルコード
$token_info = NULL;
$user_info = NULL;

$code = isset($_GET['code']) ? $_GET['code'] : '';
if ($code != '') {
   $ch = curl_init();
   curl_setopt($ch, CURLOPT_URL,
  'http://openam.example.com:8080/openam/oauth2/access_token'
                                       .'?grant_type=authorization_code'
                                       .'&redirect_uri='.urlencode('http://testservice1.example.com/cb')
                                       .'&code='.$code);
   curl_setopt($ch, CURLOPT_USERPWD, 'testservice1:************');
   curl_setopt($ch, CURLOPT_POST, true);
   curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
   $json = curl_exec($ch);
   curl_close($ch);

   $token_info = json_decode($json);
   if ($token_info != NULL && !isset($token_info->{'error'})) {
       $ch = curl_init();
       curl_setopt($ch, CURLOPT_URL,
  'http://openam.example.com:8080/openam/oauth2/userinfo');
       curl_setopt($ch, CURLOPT_POST, true);
       curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
       curl_setopt($ch, CURLOPT_HTTPHEADER,
  ["Authorization: ".$token_info->{'token_type'}." ".$token_info->{'access_token'}]);
       $json = curl_exec($ch);
       curl_close($ch);
       $user_info = json_decode($json);
   }
}

var_dump($token_info);
var_dump($user_info);

■出力結果
object(stdClass)#1 (6) {
   ["access_token"]=>
   string(36) "88090b2a-6e66-4bd8-aa32-42fef73b759d"
   ["scope"]=>
   string(12) "openid email"
   ["id_token"]=>
   string(828) "eyAidHlwIjogIkpXVCIsICJraWQiOiAiU3lsTEM2Tmp0MUtHUWt0RDlNdCswemNlUVNVPSIsICJhbGciOiAiUlMyNTYiIH0.eyAiYXRfaGFzaCI6ICJCaFJ5OC1QRWI3MWR0NTdUYmlRaVpBIiwgInN1YiI6ICJob2dlZnVnYSIsICJpc3MiOiAiaHR0cDovL29wZW5hbS5leGFtcGxlLmNvbTo4MDgwL29wZW5hbS9vYXV0aDIiLCAidG9rZW5OYW1lIjogImlkX3Rva2VuIiwgIm5vbmNlIjogImFiY2RlZiIsICJhdWQiOiBbICJ0ZXN0c2VydmljZTEiIF0sICJjX2hhc2giOiAiQlYybEdvdUdLeURwbld6NFl0NFJGZyIsICJvcmcuZm9yZ2Vyb2NrLm9wZW5pZGNvbm5lY3Qub3BzIjogIjFiOThmMmQyLTlmNDQtNDc1Yi1hMzRlLWY1OGJhNzM1YWE5NyIsICJhenAiOiAidGVzdHNlcnZpY2UxIiwgImF1dGhfdGltZSI6IDE0NjY2NTc4OTYsICJyZWFsbSI6ICIvIiwgImV4cCI6IDE0NjY2NjE3NDEsICJ0b2tlblR5cGUiOiAiSldUVG9rZW4iLCAiaWF0IjogMTQ2NjY1ODE0MSB9.ECYrjDD3G7z4gQU6ZVFn4pAcJ6Zl3p7Z5aF0B709e9p6UMaxhCKPAJwYjWCNiWajxbYe4ca69jkKqt9zuNgZIzNMPWTe-AMbaCoxZ9NG5-hfPsXM6tIER5qoZE-2IWl9e9zRwJfM1aSKis1AwjcAuKHgRfJ1fbrVy1kyRrDGhP4"
   ["token_type"]=>
   string(6) "Bearer"
   ["expires_in"]=>
   int(3599)
   ["nonce"]=>
   string(6) "abcdef"
 }
object(stdClass)#2 (2) {
   ["sub"]=>
   string(8) "hogefuga"
   ["email"]=>
   string(20) "hogefuga@example.com"
}
メールアドレスが取得できています。

何に苦労したのか?


苦労ポイント(その1)

上記OpenID Connect利用の流れの3つ目に「testservice1がユーザー情報を利用することを許可するか確認する画面」があります。
その画面を見るとsave consentというチェックボックスがあります。
このチェックボックスにチェックを入れると次回ログイン時からこの確認画面は表示されなくなる想定でしたが、チェックボックスにチェックを入れても毎回ログイン時に確認画面が表示される状態でした。
ログを参照すると
ERROR: No saved consent attribute defined in realm:/
というエラーがでていて、許可状態をうまく保存できていないのが原因でした。

設定を行いうまく動くようにはしましたが、OpenAMはデフォルトではOpenDJというLDAPを使ってユーザ情報や設定情報などを管理しています。
OpenDJに許可状態を保存するための属性を追加しないと機能しません。
LDAPに慣れている人でないとどのように属性を追加するかなども含め調べる必要があるので苦労します。

苦労ポイント(その2)

今回の開発ではユーザ情報はOpenDJではなくMySQLに保存することにしました。
MySQLに変更することはOpenAMの管理コンソールから簡単にできますが、MySQLの場合も苦労ポイント(その1)で述べた許可状態を保存する機能は動きません。
かつユーザー情報を保存するテーブルに許可状態を保存するカラムを追加すればOKというレベルでもありません。
なぜならば、OpenID Connectを複数のサービスに提供する場合、サービス毎に許可状態を保持する必要があるためです。
1ユーザ複数レコードにてその許可状態を管理することになりますが、DBにアクセスする既存のクラスは上記のような1ユーザー複数レコードでデータを管理できるようにはなっていません。
そのためDBにアクセスするクラスも変更する必要があります。
ただそれを見込んでかDBにアクセスするクラスは管理コンソールで変更できるようになっているのが救いです。

苦労ポイント(その3)

先ほどから話しに上がっている「testservice1がユーザー情報を利用することを許可するか確認する画面」は、上記OpenID Connect利用の流れの1つ目にて説明したとおり、認証エンドポイントにリダイレクトさせる際に指定するdisplayパラメータによってデバイス毎に画面を出し分けることができます。
ですが、出し分け可能なのはあくまでも「testservice1がユーザー情報を利用することを許可するか確認する画面」についてだけです。
ログイン画面はどうかというと違います。
たとえ、displayパラメータにwapを指定し携帯用としてパラメータを送ってもログイン画面は携帯用画面にはなりません。
OpenAMにはクライアントディテクションという機能があり、アクセスしたデバイス毎に画面を出し分けることができるように考慮はされていますがこれもそのままでは動きません。
ただそれを見込んでかOpenID Connectで表示されるログインページは管理コンソールで変更できるようになっているのが救いです。
独自でログイン画面を作り、REST APIで認証させるということで対応ができそうです。

所感

OpenAMはさまざまな機能が備わっています。認証関連の画面だけでなく、さまざまなREST APIも存在しています。
もちろん苦労ポイントで述べたようにデフォルトのままでは使えないものもあります。
また使えたとしても画面をもっとカスタマイズしたい(レイアウトだけでなくログイン失敗時は戻りエラーにしたいとか)、送信されるメールの内容をカスタマイズしたいなど、ユーザーと接点がある部分に関しては柔軟に対応できるようにならなければなりません。
そのためOpenAMを使うのは大変だと感じますが、OpenID Connectをフルスクラッチで作る方が努力を要すると感じます。
OpenAMにはREST APIも備わっていて、画面は独自のページを作り認証周りはREST APIを利用するなども可能です。
なんとかうまく付き合っていければと思っています。

最後に

次世代システム研究室では、アプリケーション開発や設計を行うアーキテクトを募集しています。アプリケーション開発者の方、次世代システム研究室にご興味を持って頂ける方がいらっしゃいましたら、ぜひ 募集職種一覧 からご応募をお願いします。

皆さんのご応募をお待ちしています。