モチベーション
- IDaaSを社内サービスへの適用したい
- OpenID Foundation Japanのガイドラインが伝える OpenID Connect Implicit Flowの実装方法を試してみる
- あんまりRailsでの実装事例を見ないので、これを見てもっと良い実装方法があるよ〜とかあればご意見をいただければ..!!
- 例のごとく、自分のブログからの転載
前提
- 本ドキュメントでは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実装
- 懸念点
用語の説明
- SCIM: System for Cross-domain Identity Management。ADとユーザー情報を同期するのに使ったりする
- 強制ログアウト: シングルサインオンの逆。シングルサインアウトを実現するためには必要
- OP: OpenID Provider。認証する側。今回はAzure AD。
- RP: Relying Party。認証してもらう側。SSOでログインするアプリ
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作成者が下記のような意見をしている。
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では動かない。このコミット で動作しなくなったものと思われる。
今回は参考実装2をベースにしつつ、動くように手を入れた。
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 )
- OPから割り当てられたClient ID
- Issuerの識別子
- 認証エンドポイントURI
- JWKs URI
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_onlyの検証をしていない
ガイドラインにおいて、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 クレームが含まれていないことを検証する。
ここで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
次の予定
- ALBレイヤーでOIDCができるのか確認してく