IDaaSを社内サービスに適用したかったので、その準備としてOpenID Foundation Japanのガイドラインが伝える OpenID Connect Implicit Flowの実装方法を試してみました。
前提
- 本ドキュメントではOpenID Connectの仕様については説明しない
- OpenID Foundation Japanが提供する実装ガイドに従って認証機能の実装を行う
- 認証のフローはImplicit Flowを採用している
- Authorization Code Flowは、OPとRPが直接やりとりをする必要がある
- RP側でOPからアクセスできる口を開けなければならない
- 上記理由から、ガイドラインではImplicit Flowの実装方法のみ記載されている (p.40 3.4.2 認証フロー参照)
- Authorization Code Flowは、OPとRPが直接やりとりをする必要がある
- 今回の実装では、強制ログアウト機能は未実装
- SCIMも未対応
目次
- Implicit Flowの概要
- 実際の作業とRails実装
- 懸念点
Implicit Flowの概要
( ※ https://www.openid.or.jp/news/eiwg_implementation_guideline_1.0.pdf のp.13より引用 )
Implicit FlowではOPとRPが直接やりとりをせずに、ブラウザ経由でIDトークンの受け渡しを行う。そのためRPでIDトークンが改ざんされていないことを検証する必要がある。IDトークンとはエンドユーザーのOPに対する認証結果であり、emailやprofileなどのユーザー情報、OpenID Providerの情報、リプレイ攻撃対策のための文字列などを含んでいる。
Railsの実装とAzure AD側の設定
参考にした資料
- サンプル実装: https://github.com/selmertsx/pomodoro
- 利用するgem: https://github.com/nov/openid_connect
- 参考実装1: https://github.com/nov/openid_connect_sample_rp
- 参考実装2: https://github.com/openid-foundation-japan/eiwg-guideline-samples/tree/master/sample-ruby-rp
参考実装1は、Dynamic Client Registrationを用いた OPの設定をしているものの、gem作成者が下記のような意見をしている。
https://github.com/nov/openid_connect/wiki/Client-Discovery
Some OPs are supporting Discovery without supporting Dynamic Registration, but I don't think RPs "need" to discover OP config for such OPs. Almost all RPs won't need this feature currently.
参考実装2は、シンプルな実装なので読みやすいものの、最新のopenid_connect gemでは動かない。このコミット で動作しなくなったものと思われる。
OPにRPの情報を登録する (AzureADの設定)
Azure ADに対してRP側の情報を登録する(登録方法)。登録するべき情報はリダイレクト先のURLと、ログアウト要求を受け付けるエンドポイントのURLの2点である( ガイドライン P.31 ) 。リダイレクト先のURLは完全一致で指定しなければならない(p.30)。ここで登録することによって、OPからRPにClientIDが割り振られる。
RPにOPの情報を登録する
上記手順により得られたClientIDをRP側に設定する。ガイドラインから公開鍵をJWK Set形式で、外部からアクセスできるURIに配置した場合は、下記4点のパラメータをRPに登録することとなる。( P.32~33 )
Azure ADにてこれらのパラメータを取得する方法については、このドキュメントを参照。コレをもとに実際に実装したRailsのコードは下記のようになる。
# app/models/authorization.rb class Authorization include ActiveModel::Model AUTHORIZATION_ENDPOINT = Rails.application.credentials.oidc[:authorization_endpoint].freeze ISSUER = Rails.application.credentials.oidc[:issuer].freeze IDENTIFIER = Rails.application.credentials.oidc[:identifier].freeze JWKS_URI = Rails.application.credentials.oidc[:jwks_uri].freeze attr_reader :client def initialize @client = OpenIDConnect::Client.new( issuer: ISSUER, identifier: IDENTIFIER, authorization_endpoint: AUTHORIZATION_ENDPOINT ) end private // JWKは鍵を適宜DLして利用する def jwk_json @jwks ||= JSON.parse( OpenIDConnect.http_client.get_content(JWKS_URI) ).with_indifferent_access JSON::JWK::Set.new @jwks[:keys] end end
RPからブラウザリダイレクトをしてOPに認証要求をする
ガイドラインのP.49 4.1.3 利用企業の認証サーバーへの認証要求 に、OPへの認証要求の方法が記載されている。そこではqueryパラメータに値を設定し、OPにアクセスをすることで認証の要求をすることになっている。
// OPに認証リクエストをする際のURL https://login.microsoftonline.com/xxx/oauth2/v2.0/authorize? client_id=xxx &nonce=33cf756a5fc3c69c05f40ae6baa17dac &redirect_uri=http%3A%2F%2Flocalhost%3A3000%2Fcallback &response_type=id_token &scope=openid+email+profile &state=8fd4930f285a4e9e
今回はOPとしてAzure ADを利用しているため、各種パラメータの値はAzureADの公式ドキュメントを参照する。このサンプルではユーザーの基本情報しか取得しないため、response_typeはid_tokenとした。
# app/controllers/authorizations_controller.rb 一部抜粋 class AuthorizationsController < ApplicationController def new redirect_to authz.authorization_uri(new_state, new_nonce) end private def authz @authz ||= Authorization.new end def new_state session[:state] = SecureRandom.hex(8) end def new_nonce session[:nonce] = SecureRandom.hex(16) end end
# app/models/authorization.rb 一部抜粋 class Authorization include ActiveModel::Model ... def authorization_uri(state, nonce) client.authorization_uri( response_type: 'id_token', state: state, nonce: nonce, scope: %w[openid email profile] ) end end
OPから認証結果を受け取る
OPに対して認証要求をした結果、redirect_uri で設定したURIに対して、認証結果がフラグメントとして付与される(ガイドライン p.50)。
// 受け取るレスポンス http://localhost:3000/authorization #token_type=Bearer &expires_in=3599 &scope=profile+openid+email+xxxx%2fUser.Read &id_token=eyJhbGciOiJSUzI1NiIsImtpZCI6ImlBdzUifQ.eyJpc3MiOiJodHRwczovL29wLmNvbS5 leGFtcGxlLmNvLmpwIiwic3ViIjoiZTEyMzQ1NjciLCJhdWQiOiJwV0JvUmFtOXNHIiwib m9uY2UiOiJxOGstdXBCWDRaX0EiLCJleHAiOjE0Mfafsafadfdafavfabfdaa xNDM1NzA5NzYyfQ.paxubCBAPwITlNgg-Mi_RkX5EfaRVU8YGT Z2e9UwUX1EIwBDU3i_uCqUj-yUMY6Li1rjbpHurYEw7N3ZQPdBlVy6GiMQtUFg6Ju-0MY4 rw0uiZ7HFoGenAMzoR8fNd4iIuyM4oOEF6WuV5JLfZmJ5fvgjTbAD5H-2SGeM38P8UibRy v2lB-YldI8a7nEUGXz5m5iw-WpBgk4SboMWXsyg-jr-_fbRMn9_RR76-691P45-1p8BU8w BMEwgHVARTbRg53W6dlILAl_FDWWIBxUboI9_uTKu7HCYuZecU7Uub6MXG5EMWJKUPjVuu HuvkzBsl7MdKCRZuwI1fEAU35lYQ &state=d612642b27161676 &session_state=e9ed8a15-9337-4cb1-bb8b-823dfef7a15d
今回は GET authorization/callback
をredirect先に指定したため、下記のような実装になる。なおJavaScriptのコードについてはOpenID Connect Core 15.5.3 のコードそのままとしている。
<%# app/views/authorizations/callback.html.erb %>
<h1>CallBack!!</h1>
<%= javascript_pack_tag "openid_connect" %>
// app/packs/openid_connect.js // authorization/callbackで実行されるJavaScript let params = {} const postBody = location.hash.substring(1); const regex = /([^&=]+)=([^&]*)/g; let m; while (m = regex.exec(postBody)) { params[decodeURIComponent(m[1])] = decodeURIComponent(m[2]); } var req = new XMLHttpRequest(); req.open('POST', '//' + window.location.host + '/authorization', true); req.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded'); var token = document.querySelector("meta[name='csrf-token']").attributes['content'].value; req.setRequestHeader('X-CSRF-Token', token); req.onreadystatechange = function (e) { if (req.readyState == 4) { if (req.status == 200) { console.log("success!!"); } else if (req.status == 400) { alert('There was an error processing the token') } else { alert('Something other than 200 was returned') } } }; req.send(postBody);
ID トークンの検証を行う
認証に成功した後に、IDトークンの検証を行う。IDトークンの検証においては、state値が認証要求時に指定した値と同一であるかチェックした後(p.52)、jwkを用いてIDトークンをdecodeして検証を行う。検証項目についてはガイドラインを参照とする(p.54)。
# app/controllers/authorizations_controller.rb 一部抜粋 class AuthorizationsController < ApplicationController def create if stored_state != fragment_params[:state] return render status: :unauthorized end id_token = authz.verify!(fragment_params[:id_token], stored_nonce) session[:identifier] = id_token.subject render json: id_token.raw_attributes, status: :ok end end
# app/models/authorization.rb 一部抜粋 class Authorization include ActiveModel::Model def verify!(jwt_string, nonce) id_token = decode_jwt_string(jwt_string) id_token.verify!( issuer: ISSUER, client_id: IDENTIFIER, nonce: nonce ) id_token end private def decode_jwt_string(jwt_string) OpenIDConnect::ResponseObject::IdToken.decode(jwt_string, jwk_json) end def jwk_json @jwks ||= JSON.parse( OpenIDConnect.http_client.get_content(JWKS_URI) ).with_indifferent_access JSON::JWK::Set.new @jwks[:keys] end end
// ID TokenをDecodeした結果 { "aud"=>"xxxx", // required: IDトークンを利用しようとするクライアント "iss"=>"https://login.microsoftonline.com/xxxx/v2.0", //required: ID Tokenの発行者(Issuer)を表す識別子 "iat"=>1534834161, // require: IDトークンが発行された時刻。UTC "nbf"=>1534834161, "exp"=>1534838061, // required: IDトークンの有効期限 "aio"=> "xxx", "name"=>"selmertsx", "nonce"=>"fcecee85d465e7df8e6d225c8e00bf15", // option: リプレイ攻撃の対策に利用される文字列 "oid"=>"xxx", "preferred_username"=>"shuhei.morioka@hogehoge.com", "sub"=>"xxx", //required: 認証されたユーザーを表す識別子. Issuer内で一意 "tid"=>"xxx", "uti"=>"xxx", "ver"=>"2.0" }
懸念点
- openid_connectのgemではlogout_termの検証をしていない
openid_connectのgemではlogout_termの検証をしていない
ガイドラインにおいて、IDトークンの検証として、最低限下記の項目について検証することとしている。(p.54)
- iss クレームが、認証要求を送った利用企業の認証サーバーの URL と一致することを検証する。
- aud クレームが、自クラウドサービスに割り当てられた client_id であることを検証する。 もし aud クレームが複数の値を持つ場合は、OpenID Connect Core 1.0 [OpenID.Core] の Section 3.1.3.7 の定義に従った追加の検証が必要である。
- ID トークンの署名を、RS256 アルゴリズムを用いて検証する。 ID トークンの base64url エンコーディングされた 1 番目、2 番目のパートを '.' で連結し た文字列を、ハッシュ関数に SHA-256 を使うように構成された RSASSA-PKCS1-v1_5 署名検証器にかけることで検証を行なう。 具体的な手順は JWS [RFC7515] の Section 5.2, Section A.2 などに示されている。署名 検証に用いる公開鍵の取得方法は [4.1.9 節] に示す。 もし、ID トークンに RS256 以外の署名アルゴリズムが指定されており、その署名をク ラウドサービスが検証可能な場合は、その検証で代替しても良い。
- 現在時刻が exp クレームで指定された時刻よりも前であることを検証する。
- nonce クレームが、クライアント(ブラウザ)セッションと紐付いた nonce 値と一致す ることを検証する。
- logout_only クレームが含まれていないことを検証する。
ここで3と6の項目について、現在のopenid_connectのライブラリでは検証がされていないように見受けられた。現在、これが検証されていない場合、どのようなリスクがあるのか調査中。
# openid_connect/lib/openid_connect/response_object/id_token.rb # https://github.com/nov/openid_connect/blob/007d35f249b028a43391550153c22e9cf006e661/lib/openid_connect/response_object/id_token.rb#L24-L35 def verify!(expected = {}) raise ExpiredToken.new('Invalid ID token: Expired token') unless exp.to_i > Time.now.to_i raise InvalidIssuer.new('Invalid ID token: Issuer does not match') unless iss == expected[:issuer] raise InvalidNonce.new('Invalid ID Token: Nonce does not match') unless nonce == expected[:nonce] # aud(ience) can be a string or an array of strings unless Array(aud).include?(expected[:audience] || expected[:client_id]) raise InvalidAudience.new('Invalid ID token: Audience does not match') end true end
その他
ユーザー情報の受け取り方
ユーザー属性情報の受け取り方について、scopeを用いるものとUserInfoエンドポイントを提供するものがある。Azure ADにおいては、今回の実装ではUserInfoエンドポイントを使わずに、scopeに値を追加する形にした。