Help us understand the problem. What is going on with this article?

OpenID Foundationのガイドラインに沿ったRailsでのOIDC Implicit Flow実装

More than 1 year has passed since last update.

モチベーション

  • IDaaSを社内サービスへの適用したい
  • OpenID Foundation Japanのガイドラインが伝える OpenID Connect Implicit Flowの実装方法を試してみる
  • あんまりRailsでの実装事例を見ないので、これを見てもっと良い実装方法があるよ〜とかあればご意見をいただければ..!! :pray:
  • 例のごとく、自分のブログからの転載

前提

  • 本ドキュメントではOpenID Connectの仕様については説明しない
  • OpenID Foundation Japanが提供する実装ガイドに従って認証機能の実装を行う
  • 認証のフローはImplicit Flowを採用している
    • Authorization Code Flowは、OPとRPが直接やりとりをする必要がある
      • RP側でOPからアクセスできる口を開けなければならない
    • 上記理由から、ガイドラインではImplicit Flowの実装方法のみ記載されている (p.40 3.4.2 認証フロー参照)
  • 今回の実装では、強制ログアウト機能&SCIMは未実装

目次

  • 用語の説明
  • Implicit Flowの概要
  • 実際の作業とRails実装
  • 懸念点

用語の説明

  • SCIM: System for Cross-domain Identity Management。ADとユーザー情報を同期するのに使ったりする
  • 強制ログアウト: シングルサインオンの逆。シングルサインアウトを実現するためには必要
  • OP: OpenID Provider。認証する側。今回はAzure AD。
  • RP: Relying Party。認証してもらう側。SSOでログインするアプリ

Implicit Flowの概要

スクリーンショット 2018-08-20 14.15.15.png

( ※ 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側の設定

参考にした資料

参考実装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では動かない。このコミット で動作しなくなったものと思われる。

今回は参考実装2をベースにしつつ、動くように手を入れた。

OPにRPの情報を登録する (AzureADの設定)

スクリーンショット 2018-08-21 10.44.22.png

20180813172651 (1).png

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)

  1. iss クレームが、認証要求を送った利用企業の認証サーバーの URL と一致することを検証する。
  2. aud クレームが、自クラウドサービスに割り当てられた client_id であることを検証する。 もし aud クレームが複数の値を持つ場合は、OpenID Connect Core 1.0 [OpenID.Core] の Section 3.1.3.7 の定義に従った追加の検証が必要である。
  3. 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 以外の署名アルゴリズムが指定されており、その署名をク ラウドサービスが検証可能な場合は、その検証で代替しても良い。
  4. 現在時刻が exp クレームで指定された時刻よりも前であることを検証する。
  5. nonce クレームが、クライアント(ブラウザ)セッションと紐付いた nonce 値と一致す ることを検証する。
  6. logout_only クレームが含まれていないことを検証する。

ここで6の項目について、現在のopenid_connectのライブラリでは検証がされていないように見受けられた。現在、これが検証されていない場合、どのようなリスクがあるのか調査中。(強制ログアウトの検証ができないくらいの認識で良いのだろうか :thinking: )

# 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

次の予定

selmertsx
Qiitaは公開できるメモ帳
http://selmertsx.hatenablog.com/
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
No comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
ユーザーは見つかりませんでした