DeviseベースでSAML SSOを実現する 「devise_saml_authenticatable」のソースコードを追ってみた

こんにちは!kossyです!




今回は、DeviseベースでSAML SSOを実現する 「devise_saml_authenticatable」のソースコードを追ってみたので、備忘録としてブログに残してみたいと思います。



環境

Ruby 2.6.8
Rails 6.0.4
MacOS BigSur
devise 4.8.0
devise_saml_authenticatable 1.7.0



newアクション

github.com

上記のセクションを見ると、

The attribute mappings are very dependent on the way the IdP encodes the attributes. In these examples the attributes are given in URN style. Other IdPs might provide them as OID's, or by other means.

属性マッピングは、IdPが属性をエンコードする方法に大きく依存します。 これらの例では、属性はURNスタイルで指定されています。 他のIdPは、それらをOIDとして、または他の手段で提供する場合があります。

You are now ready to test it against an IdP.

これで、IdPに対してテストする準備が整いました。

When the user visits /users/saml/sign_in they will be redirected to the login page of the IdP.

ユーザーが/ users / saml / sign_inにアクセスすると、IdPのログインページにリダイレクトされます。

Upon successful login the user is redirected to the Devise user_root_path.

ログインに成功すると、ユーザーはDeviseのuser_root_pathにリダイレクトされます。

出典: https://github.com/apokalipto/devise_saml_authenticatable#configuring-handling-of-idp-requests-and-responses

とのことなので、まずはsaml_sessions_controller.rbのnewアクションのソースコードから追ってみます。

def new
  idp_entity_id = get_idp_entity_id(params)
  request = OneLogin::RubySaml::Authrequest.new
  auth_params = { RelayState: relay_state } if relay_state
  action = request.create(saml_config(idp_entity_id), auth_params || {})
  if request.respond_to?(:request_id)
    session[:saml_transaction_id] = request.request_id
  end
  redirect_to action
end

まずはget_idp_entity_idメソッドから見てみます。

def get_idp_entity_id(params)
  idp_entity_id_reader.entity_id(params)
end

# idp_entity_id_readerメソッドはこちら
def idp_entity_id_reader
  if Devise.idp_entity_id_reader.respond_to?(:entity_id)
    Devise.idp_entity_id_reader
  else
    @idp_entity_id_reader ||= Devise.idp_entity_id_reader.constantize
  end
end

Devise Moduleのidp_entity_id_readerにentity_idが生えて入れば、idp_entity_id_readerを返却し、

そうでなければDevise.idp_entity_id_reader.constantizeを実行してインスタンス変数としています。(DeviseSamlAuthenticatable::DefaultIdpEntityIdReaderクラスが返ります)

DeviseSamlAuthenticatable::DefaultIdpEntityIdReaderとはどんなクラスでしょうか。

module DeviseSamlAuthenticatable
  class DefaultIdpEntityIdReader
    def self.entity_id(params)
      if params[:SAMLRequest]
        OneLogin::RubySaml::SloLogoutrequest.new(
          params[:SAMLRequest],
          settings: Devise.saml_config,
          allowed_clock_drift: Devise.allowed_clock_drift_in_seconds,
        ).issuer
      elsif params[:SAMLResponse]
        OneLogin::RubySaml::Response.new(
          params[:SAMLResponse],
          settings: Devise.saml_config,
          allowed_clock_drift: Devise.allowed_clock_drift_in_seconds,
        ).issuers.first
      end
    end
  end
end

paramsを見てSAMLのログアウトリクエスインスタンスを生成してissuerを返却するか、

SAMLレスポンスのissuerを返却するかを司るクラスでした。

binding.pryで処理を止めて試したところ、idp_entity_idはnilが返りました。(ifにもelsifにも引っかからないので当然ですが)

その後の処理はSAMLRequestを生成して、SAMLのConfigを元にURL(= action)を生成してそのURLにリダイレクトさせています。

URLは以下のようなものです。

"https://idp_endpoint.com/saml2/idp/?SAMLRequest=hVPBjtowEP2V3HwKMSHb2bUIEgVVRdq2CNIe9rJynEmx4tipZ1Lo39cJUHFo6SGy5HnvzZvnyRxlazqx7Olgd%2FCjB6RoiQietLMrZ7Fvwe%2FB%2F9QKvu5ecnYg6kSSGKekOTgkMeOcJ31gYDJoJTJIsWgdhLSVg8qZg4E0lCZH5xtsXakNTJRrR1Ka6KpLGtDYN9A4POhGHiWLNuucvXFeVTWvVfyclTzOnrIsLqfveFyWz2n46qcH%2FhigiD1sLJK0lLOUp9N4yuMZL%2FiDmD2KjL%2ByaOsdOeXMe20rbb%2FnrPdWOIkahZUtoCAl9stPLyKdcFGeQSg%2BFsU23n7ZFyz6FqYcJwoAFp1aY1EM%2Fu8ryWuet5TuPqe7WGWL%2BYAW43h%2B8d%2F0WyBZSZLz5JY2P7%2Fy59Bms946o9WvaGmMO648SIKcke%2BBRR%2BcbyXdNzbc6CquR6ggLy1qsMSSa5PLFkE17lRYIYITRSvXdtJrHMKDk1R0HewWtTIhqh3Ui7tpKqEGXLjehiOsUzW8K6jQshjsdM7TZfy%2Fip9r%2FzD6p3r7Ryx%2BAw%3D%3D"

OneLogin::RubySaml::Authrequestのコードリーディングは以前書いた拙著でも取り上げています。

kossy-web-engineer.hatenablog.com

createアクション

saml_sessions_controller.rbにはcreateアクションは実装されていませんので、Devise::SessionsControllerのcreateアクションを見てみます。

  # POST /resource/sign_in
  def create
    self.resource = warden.authenticate!(auth_options)
    set_flash_message!(:notice, :signed_in)
    sign_in(resource_name, resource)
    yield resource if block_given?
    respond_with resource, location: after_sign_in_path_for(resource)
  end

createアクションの肝はwarden.authenticate!です。

こちら、まずはbinding.pryでデバッグしながらコードを追ってみます。

  # POST /resource/sign_in
  def create
    binding.pry
    self.resource = warden.authenticate!(auth_options)
    set_flash_message!(:notice, :signed_in)
    sign_in(resource_name, resource)
    yield resource if block_given?
    respond_with resource, location: after_sign_in_path_for(resource)
  end

/users/saml/sign_in にアクセスして、IdP側で認証処理を行った後、SPにリダイレクトするときにcreateアクションが呼ばれます。

pry-byebug Gemのstepメソッドを利用してwarden.authenticate!の処理の詳細を追ってみます。

$ step

$ From: /usr/local/bundle/gems/warden-1.2.9/lib/warden/proxy.rb:133 Warden::Proxy#authenticate!:

def authenticate!(*args)
  user, opts = _perform_authentication(*args)
  throw(:warden, opts) unless user
  user
end

# 何度かstepを実行

$ /usr/local/bundle/gems/warden-1.2.9/lib/warden/proxy.rb:354 Warden::Proxy#_run_strategies_for:

# Run the strategies for a given scope
def _run_strategies_for(scope, args) #:nodoc:
  self.winning_strategy = @winning_strategies[scope]
  return if winning_strategy && winning_strategy.halted?

  # Do not run any strategy if locked
  return if @locked

  if args.empty?
    defaults   = @config[:default_strategies]
    strategies = defaults[scope] || defaults[:_all]
  end

  (strategies || args).each do |name|
    strategy = _fetch_strategy(name, scope)
    next unless strategy && !strategy.performed? && strategy.valid?
    catch(:warden) do
      _update_winning_strategy(strategy, scope)
    end

    strategy._run!
    _update_winning_strategy(strategy, scope)
    break if strategy.halted?
  end
end

$ self.winning_strategy = @winning_strategies[scope]
=> nil

$ args.empty?
=> true

$ defaults   = @config[:default_strategies]
=> {:user=>[:saml_authenticatable, :rememberable, :database_authenticatable]} # Userモデルの devise メソッドに指定したモジュールの一部が列挙されます

$  strategies = defaults[scope] || defaults[:_all]
=> [:saml_authenticatable, :rememberable, :database_authenticatable]

# 何度かstep

$ /usr/local/bundle/gems/warden-1.2.9/lib/warden/proxy.rb:380 Warden::Proxy#_fetch_strategy:

# Fetches strategies and keep them in a hash cache.
def _fetch_strategy(name, scope)
  @strategies[scope][name] ||= if klass = Warden::Strategies[name]
    klass.new(@env, scope)
  elsif @config.silence_missing_strategies?
    nil
  else
    raise "Invalid strategy #{name}"
  end
end

$ @strategies[scope][name]
=> nil

$ klass = Warden::Strategies[name]
=> :saml_authenticatable

# 何度かstepし _run_strategies_for に戻る

$ strategy.class
=>  Devise::Strategies::SamlAuthenticatable

ここでようやく Devise::Strategies::SamlAuthenticatable クラスのインスタンスが登場しました。

この後、 strategy.valid? で Devise::Strategies::SamlAuthenticatableのvalid?メソッドが呼ばれます。

github.com

def valid?
  if params[:SAMLResponse]
    OneLogin::RubySaml::Response.new(
      params[:SAMLResponse],
      response_options,
    )
  else
    false
  end
end

params[:SAMLResponse]が存在していれば OneLogin::RubySaml::Responseインスタンスが返り値となり、なければfalseが返ります。

余談なんですが、Rubyは基本的にメソッドの接尾辞に?がついている場合はTrue or False を返す慣習があると思っていたのですが、

当該コードはその慣習に従ってないですね、、、少しモヤモヤします。

また何度かstepとnextを実行していると、 Devise::Strategies::SamlAuthenticatable#authenticate!が実行されます。

def authenticate!
  parse_saml_response
  retrieve_resource unless self.halted?
  unless self.halted?
    @resource.after_saml_authentication(@response.sessionindex)
    success!(@resource)
  end
end

SamlResponseをparseして、 strategyの実行が中止されていなければ retrieve_resource を実行しています。

halted?はwardenのコードです。

github.com

def retrieve_resource
  @resource = mapping.to.authenticate_with_saml(@response, params[:RelayState])
  if @resource.nil?
    failed_auth("Resource could not be found")
  end
end

mappingは一体何でしょう。

$ mapping.class
=> Devise::Mapping

$  /usr/local/bundle/gems/devise-4.8.0/lib/devise/strategies/base.rb:14 Devise::Strategies::Base#mapping:

def mapping
  @mapping ||= begin
    mapping = Devise.mappings[scope]
    raise "Could not find mapping for #{scope}" unless mapping
    mapping
  end
end

Deviseのリソースとルーティングをマッピングするクラスのインスタンスが返りました。

mapping.toを実行してみます。

$  @mapping.to
=> User(id: integer, email: string, encrypted_password: string, reset_password_token: string, reset_password_sent_at: datetime, remember_created_at: datetime, created_at: datetime, updated_at: datetime)

Userクラスがcallされています。なので、authenticate_with_samlはUserモデルに対して実行されることになりますね。

def authenticate_with_saml(saml_response, relay_state)
  key = Devise.saml_default_user_key
  decorated_response = ::SamlAuthenticatable::SamlResponse.new(
    saml_response,
    attribute_map(saml_response),
  )
  if Devise.saml_use_subject
    auth_value = saml_response.name_id
  else
    auth_value = decorated_response.attribute_value_by_resource_key(key)
  end
  auth_value.try(:downcase!) if Devise.case_insensitive_keys.include?(key)

  resource = Devise.saml_resource_locator.call(self, decorated_response, auth_value)

  raise "Only one validator configuration can be used at a time" if Devise.saml_resource_validator && Devise.saml_resource_validator_hook
  if Devise.saml_resource_validator || Devise.saml_resource_validator_hook
    valid = if Devise.saml_resource_validator then Devise.saml_resource_validator.new.validate(resource, saml_response)
            else Devise.saml_resource_validator_hook.call(resource, decorated_response, auth_value)
            end
    if !valid
      logger.info("User(#{auth_value}) did not pass custom validation.")
      return nil
    end
  end

  if resource.nil?
    if Devise.saml_create_user
      logger.info("Creating user(#{auth_value}).")
      resource = new
    else
      logger.info("User(#{auth_value}) not found.  Not configured to create the user.")
      return nil
    end
  end

  if Devise.saml_update_user || (resource.new_record? && Devise.saml_create_user)
    Devise.saml_update_resource_hook.call(resource, decorated_response, auth_value)
  end

  resource
end

ここがdevise_saml_authenticatableを使ったSSOのログインの仕組みの肝だと思うのでじーっくり読んでみます。

# デフォルトのユーザーキーを設定します。 ユーザーはこのキーで検索されます。 認証応答に属性が含まれていることを確認してください。
$ key = Devise.saml_default_user_key
=> :email

# SAMLのレスポンスを加工
$ decorated_response = ::SamlAuthenticatable::SamlResponse.new(saml_response, attribute_map(saml_response))

# この値を設定して、電子メールを比較する情報としてSubjectまたはSAMLアサーションを使用できます。 設定しない場合、EメールはSAMLアサーション属性から抽出されます。
$ Devise.saml_use_subject
=> true

$ auth_value = saml_response.name_id
=> "your_idp_id@your_idp_domain"

# 大文字と小文字を区別しない認証キーを構成します。 これらのキーは、ユーザーの作成または変更時、およびユーザーの認証または検索に使用されるときに小文字になります。 デフォルトは:emailです。
$ auth_value.try(:downcase!) if Devise.case_insensitive_keys.include?(key)

$ resource = Devise.saml_resource_locator.call(self, decorated_response, auth_value)
=> #<User id: 1, email: "your_idp_id@your_idp_domain", created_at: "2021-10-17 13:42:43", updated_at: "2021-11-12 13:18:25">

# 取得したリソースと応答を取得直後に取得し、有効な場合はtrueを返す#validateメソッドを実装します。 
# Falseを指定すると、認証が失敗します。 saml_resource_validatorとsaml_resource_validator_hookのいずれか1つのみを使用できます。
# 両方とも Trueだと、「一度に使用できるバリデーター構成は1つだけです」というエラーがraiseします。
$ Devise.saml_resource_validator
=> nil

$ Devise.saml_resource_validator_hook
=> nil

# 自分の環境では両方nilだったためvalidateは省略

$ resource.nil?
=> false

# ログインに成功した後、ユーザーの属性を更新します。 (デフォルトはfalse)
$ Devise.saml_update_user
=> false

$ (resource.new_record? && Devise.saml_create_user)
=> false

一通り追ってみましたが、実際にユーザーを取得しているっぽい処理はDevise.saml_resource_locator.callメソッドだと思われるので、stepでどんな処理をしているか見に行ってみます。

# デフォルトのリソースロケーター。 saml_default_user_keyとauth_valueを使用してユーザーをresolveします。 詳細については、saml_resource_locatorを参照してください。
# /lib/devise_saml_authenticatable.rb:127

mattr_reader :saml_default_resource_locator
@@saml_default_resource_locator = Proc.new do |model, saml_response, auth_value|
  model.where(Devise.saml_default_user_key => auth_value).first
end

$ model
=> User(id: integer, email: string, ...

$ saml_response.class
=> SamlAuthenticatable::SamlResponse

$ auth_value
=> IdP側のemailアドレスが返ります# ユーザーを検索しに行く
$ model.where(Devise.saml_default_user_key => auth_value).first
=> #<User id: 1, email: "idp_mailaddress"

なるほどモデル名に対してwhereメソッドでDevise.saml_default_user_keyに指定した属性をauth_valueで検索しに行く処理でした。

これも余談ですが、where + first は find_by で置き換えられると思うので、細かいですが修正PRを出してもいいかもしれないですね、、、

これで概ね追えたと思います。SamlAuthenticatable::SamlResponseクラスの処理や、attribute-map.yml周りのコードは別の記事で追ってみたいと思います。(もう12000字超えてるし、、、)