devise_saml_authenticatableのSamlAuthenticatable::SamlResponseのソースコードを追ってみる

こんにちは!kossyです!




今回は、前回のブログで追いきれなかった、SamlAuthenticatable::SamlResponseのソースコードを追ってみようと思います。

前回のブログ

kossy-web-engineer.hatenablog.com



環境
Ruby 2.6.8
Rails 6.0.4
MacOS BigSur
devise_saml_authenticatable 1.7.0




呼び出し元のおさらい

呼び出し元の処理はこちらでした。

github.com

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

authenticate_with_samlメソッドの中で、SamlAuthenticatable::SamlResponse.newが呼び出されていましたね。


SamlAuthenticatable::SamlResponseクラス

github.com

改行を含めても16行の小さいクラスでした。

require 'devise_saml_authenticatable/saml_mapped_attributes'

module SamlAuthenticatable
  class SamlResponse
    attr_reader :raw_response, :attributes

    def initialize(saml_response, attribute_map)
      @attributes = ::SamlAuthenticatable::SamlMappedAttributes.new(saml_response.attributes, attribute_map)
      @raw_response = saml_response
    end

    def attribute_value_by_resource_key(key)
      attributes.value_by_resource_key(key)
    end
  end
end

initializeメソッドの中で SamlAuthenticatable::SamlMappedAttributesというクラスがnewされていますので、処理を見てみます。

と思ったんですが、authenticate_with_samlメソッドの中でattribute_mapというメソッドが呼ばれてますね、、、こちらから見てみましょうか。

attribute_map

定義元はSamlAuthenticatableモジュールの84行目です。

github.com

def attribute_map(saml_response = nil)
  attribute_map_resolver.new(saml_response).attribute_map
end

def attribute_map_resolver
  if Devise.saml_attribute_map_resolver.respond_to?(:new)
    Devise.saml_attribute_map_resolver
  else
    Devise.saml_attribute_map_resolver.constantize
  end
end

Devise.saml_attribute_map_resolverに対して、newが定義されていれば Devise.saml_attribute_map_resolver.newを呼び出し、

newが定義されていなければ Devise.saml_attribute_map_resolverをcontantizeメソッドで文字列をクラス化した後にnewを呼び出していました。

github.com

module DeviseSamlAuthenticatable
  class DefaultAttributeMapResolver
    def initialize(saml_response)
      @saml_response = saml_response
    end

    def attribute_map
      return {} unless File.exist?(attribute_map_path)

      attribute_map = YAML.load(File.read(attribute_map_path))
      if attribute_map.key?(Rails.env)
        attribute_map[Rails.env]
      else
        attribute_map
      end
    end

    private

    attr_reader :saml_response

    def attribute_map_path
      Rails.root.join("config", "attribute-map.yml")
    end
  end
end

initializeメソッドでは引数で渡されたsaml_responseをインスタンス変数に代入しているだけですね。

attribute_mapメソッドは、configディレクトリ直下にattribute_map.ymlファイルが存在していなければ {} を返却し、

存在していれば、YAML.loadで読み出して、attribute_mal.ymlにproduction: のように環境毎に定義が分かれていれば、その環境の値を読み出し、

定義が分かれていなければ全てのattribute_map.ymlの値を返しています。この実装方法、他にも応用が効きそうな気がしています。

attribute_map.ymlファイルの中身はこんな感じになるかと思います。

"urn:mace:dir:attribute-def:email": "email"

SAMLの属性とdeviseの属性をmappingする用途で使っています。

この辺りはコンソールで試してみましょうか。

# Instead of storing the attribute_map in attribute-map.yml, store it in the database, or set it programatically
# attribute_mapをattribute-map.ymlに保存する代わりに、データベースに保存するか、プログラムで設定します

# 私の環境ではfalseが返りました。
$ Devise.saml_attribute_map_resolver.respond_to?(:new)
=> false

# newは DeviseSamlAuthenticatable::DefaultAttributeMapResolver に対して呼び出される
$ Devise.saml_attribute_map_resolver.constantize
=> DeviseSamlAuthenticatable::DefaultAttributeMapResolver

# 私の環境ではattribute-map.ymlを定義しているので返り値が存在する
$ attribute_map_path
=> #<Pathname:/app/config/attribute-map.yml>

$ File.exist?(attribute_map_path)
=> true

$ attribute_map
=> {"urn:mace:dir:attribute-def:email"=>"email"}

attribute_mapメソッドの動きが理解できたところで、 SamlAuthenticatable::SamlResponseクラスに戻ります。

SamlAuthenticatable::SamlMappedAttributesクラス

deviseとsaml_responseのattrのmappingを担当するクラスですね。

github.com

module SamlAuthenticatable
  class SamlMappedAttributes
    def initialize(attributes, attribute_map)
      @attributes = attributes
      @attribute_map = attribute_map
    end

    def saml_attribute_keys
      @attribute_map.keys
    end

    def resource_keys
      @attribute_map.values
    end

    def value_by_resource_key(key)
      str_key = String(key)

      # Find all of the SAML attributes that map to the resource key
      attribute_map_for_key = @attribute_map.select { |_, resource_key| String(resource_key) == str_key }

      saml_value = nil

      # Find the first non-nil value
      attribute_map_for_key.each_key do |saml_key|
        saml_value = value_by_saml_attribute_key(saml_key)

        break unless saml_value.nil?
      end

      saml_value
    end

    def value_by_saml_attribute_key(key)
      @attributes[String(key)]
    end
  end
end

initialize時に引数で渡ってきたSamlResponseの属性を基に、attribute_mapの値と比較しつつ、合致した値を返却するような処理が含まれていました。

こちらもコンソールで試してみましょう。

$ decorated_response.attribute_value_by_resource_key(key)
=> nil

# stepでメソッド内部に入り込んでなぜnilが返るのか検証してみました

    16: def value_by_resource_key(key)
 => 17:   str_key = String(key)
    18:
    19:   # Find all of the SAML attributes that map to the resource key
    20:   attribute_map_for_key = @attribute_map.select { |_, resource_key| String(resource_key) == str_key }
    21:
    22:   saml_value = nil
    23:
    24:   # Find the first non-nil value
    25:   attribute_map_for_key.each_key do |saml_key|
    26:     saml_value = value_by_saml_attribute_key(saml_key)
    27:
    28:     break unless saml_value.nil?
    29:   end
    30:
    31:   saml_value
    32: end

$ key
=> :email

$ str_key = String(key)
=> "email"

$  attribute_map_for_key = @attribute_map.select { |_, resource_key| String(resource_key) == str_key }
=> {"urn:mace:dir:attribute-def:email"=>"email"}

    34: def value_by_saml_attribute_key(key)
 => 35:   @attributes[String(key)]
    36: end

# @attributesがそもそも{}だったからでした
$  @attributes[String(key)]
=> nil

概ね挙動の理解ができました。

まとめ

SAMLResponseとdeviseのattrのマッピングの実現方法は、普段の実装でも活かせるような気がしています。(ymlファイルの読み込み等々)

一通り挙動を追ってみて思ったのですが、やはりSAMLによるSSO周りの仕様の理解が追いついてないのをコードを読めば読むほど痛感しますね、、、

近いうちに、「この値を設定していないとこのレスポンスが返ってくる」とか、
「この値が間違っているとこのエラーになる」みたいなのを体当たりで検証してみたいと思います。(IdP側のコードも読んでみたいな、、、)