SAMLでのSSOを実現するGem「ruby-saml」のソースコードを追ってみる(SamlRequest編)

こんにちは!kossyです!



今回は、SAMLでのSSOを実現するGem「ruby-saml」のソースコードを追ってみた(SamlRequestだけ)ので、
備忘録としてブログに残してみたいと思います。




環境
Ruby 2.6.8
Rails 6.0.4.1
MacOS Catalina





なお、IdPにはLINE WORKSを用いていると仮定してコードリーディングしてみます。

そして、リーディング元のレポジトリは拙著で作成したアプリケーションとします。

kossy-web-engineer.hatenablog.com


sso アクション

まずはSAMLリクエストを送信するアクションのソースコードから読んでみます。

class SamlController < ApplicationController
  # 省略

  def sso
    settings = Account.get_saml_settings(get_url_base)
    if settings.nil?
      render :action => :no_settings
      return
    end

    request = OneLogin::RubySaml::Authrequest.new

    redirect_to(request.create(settings))
  end

  # 省略
end

requestオブジェクトのcreateメソッドの処理を見てみます。

def create(settings, params = {})
  params = create_params(settings, params)
  params_prefix = (settings.idp_sso_service_url =~ /\?/) ? '&' : '?'
  saml_request = CGI.escape(params.delete("SAMLRequest"))
  request_params = "#{params_prefix}SAMLRequest=#{saml_request}"
  params.each_pair do |key, value|
    request_params << "&#{key.to_s}=#{CGI.escape(value.to_s)}"
  end
  raise SettingError.new "Invalid settings, idp_sso_service_url is not set!" if settings.idp_sso_service_url.nil? or settings.idp_sso_service_url.empty?
  @login_url = settings.idp_sso_service_url + request_params
end


create_paramsが実際にパラメータを生成する処理っぽいので見てみます。

def create_params(settings, params={})
  # The method expects :RelayState but sometimes we get 'RelayState' instead.
  # Based on the HashWithIndifferentAccess value in Rails we could experience
  # conflicts so this line will solve them.
  relay_state = params[:RelayState] || params['RelayState']

  if relay_state.nil?
    params.delete(:RelayState)
    params.delete('RelayState')
  end

  request_doc = create_authentication_xml_doc(settings)
  request_doc.context[:attribute_quote] = :quote if settings.double_quote_xml_attribute_values

  request = ""
  request_doc.write(request)

  Logging.debug "Created AuthnRequest: #{request}"

  request = deflate(request) if settings.compress_request
  base64_request = encode(request)
  request_params = {"SAMLRequest" => base64_request}

  if settings.idp_sso_service_binding == Utils::BINDINGS[:redirect] && settings.security[:authn_requests_signed] && settings.private_key
    params['SigAlg']    = settings.security[:signature_method]
    url_string = OneLogin::RubySaml::Utils.build_query(
      :type => 'SAMLRequest',
      :data => base64_request,
      :relay_state => relay_state,
      :sig_alg => params['SigAlg']
    )
    sign_algorithm = XMLSecurity::BaseDocument.new.algorithm(settings.security[:signature_method])
    signature = settings.get_sp_key.sign(sign_algorithm.new, url_string)
    params['Signature'] = encode(signature)
  end

  params.each_pair do |key, value|
    request_params[key] = value.to_s
  end

  request_params
end

長い、、、じっくり見てみます。まずはコメントアウトの翻訳から。

create_params(settings, params={})

The method expects :RelayState but sometimes we get 'RelayState' instead.
Based on the HashWithIndifferentAccess value in Rails we could experience
conflicts so this line will solve them.

このメソッドは:RelayStateを想定していますが、代わりに 'RelayState' を取得する場合があります。
RailsのHashWithIndifferentAccess値に基づいて、競合が発生する可能性があるため、この行で競合を解決します。

出典: https://github.com/onelogin/ruby-saml/blob/master/lib/onelogin/ruby-saml/authrequest.rb#L54

シンボルでも文字列でのkey指定でもRelayState(検証後の遷移先)を取得できるようにしているようです。

もしrelay_stateの値がnilならparamsからRelayStateを削除しています。

create_authentication_xml_docメソッドを見る必要がありそう。

def create_authentication_xml_doc(settings)
  document = create_xml_document(settings)
  sign_document(document, settings)
end

create_xml_documentメソッド、長いぞ、、、

create_xml_document

def create_xml_document(settings)
  time = Time.now.utc.strftime("%Y-%m-%dT%H:%M:%SZ")

  request_doc = XMLSecurity::Document.new
  request_doc.uuid = uuid

  root = request_doc.add_element "samlp:AuthnRequest", { "xmlns:samlp" => "urn:oasis:names:tc:SAML:2.0:protocol", "xmlns:saml" => "urn:oasis:names:tc:SAML:2.0:assertion" }
  root.attributes['ID'] = uuid
  root.attributes['IssueInstant'] = time
  root.attributes['Version'] = "2.0"
  root.attributes['Destination'] = settings.idp_sso_service_url unless settings.idp_sso_service_url.nil? or settings.idp_sso_service_url.empty?
  root.attributes['IsPassive'] = settings.passive unless settings.passive.nil?
  root.attributes['ProtocolBinding'] = settings.protocol_binding unless settings.protocol_binding.nil?
  root.attributes["AttributeConsumingServiceIndex"] = settings.attributes_index unless settings.attributes_index.nil?
  root.attributes['ForceAuthn'] = settings.force_authn unless settings.force_authn.nil?

  # Conditionally defined elements based on settings
  if settings.assertion_consumer_service_url != nil
    root.attributes["AssertionConsumerServiceURL"] = settings.assertion_consumer_service_url
  end
  if settings.sp_entity_id != nil
    issuer = root.add_element "saml:Issuer"
    issuer.text = settings.sp_entity_id
  end

  if settings.name_identifier_value_requested != nil
    subject = root.add_element "saml:Subject"

    nameid = subject.add_element "saml:NameID"
    nameid.attributes['Format'] = settings.name_identifier_format if settings.name_identifier_format
    nameid.text = settings.name_identifier_value_requested

    subject_confirmation = subject.add_element "saml:SubjectConfirmation"
    subject_confirmation.attributes['Method'] = "urn:oasis:names:tc:SAML:2.0:cm:bearer"
  end

  if settings.name_identifier_format != nil
    root.add_element "samlp:NameIDPolicy", {
        # Might want to make AllowCreate a setting?
        "AllowCreate" => "true",
        "Format" => settings.name_identifier_format
    }
  end

  if settings.authn_context || settings.authn_context_decl_ref

    if settings.authn_context_comparison != nil
      comparison = settings.authn_context_comparison
    else
      comparison = 'exact'
    end

    requested_context = root.add_element "samlp:RequestedAuthnContext", {
      "Comparison" => comparison,
    }

    if settings.authn_context != nil
      authn_contexts_class_ref = settings.authn_context.is_a?(Array) ? settings.authn_context : [settings.authn_context]
      authn_contexts_class_ref.each do |authn_context_class_ref|
        class_ref = requested_context.add_element "saml:AuthnContextClassRef"
        class_ref.text = authn_context_class_ref
      end
    end

    if settings.authn_context_decl_ref != nil
      authn_contexts_decl_refs = settings.authn_context_decl_ref.is_a?(Array) ? settings.authn_context_decl_ref : [settings.authn_context_decl_ref]
      authn_contexts_decl_refs.each do |authn_context_decl_ref|
        decl_ref = requested_context.add_element "saml:AuthnContextDeclRef"
        decl_ref.text = authn_context_decl_ref
      end
    end
  end

  request_doc
end

長いですが、SAMLのキモであるXMLドキュメントの構築部分なので気合いいれて読んで試してみます。

# rails c

$ settings = Account.get_saml_settings("http://localhost:3000")

$ request = OneLogin::RubySaml::Authrequest.new

$ time = Time.now.utc.strftime("%Y-%m-%dT%H:%M:%SZ")
=> "2021-10-23T12:45:30Z"

$ request_doc = XMLSecurity::Document.new
=> <UNDEFINED/>

# uuidはSecureRandom.uuid
# OneLogin::RubySaml::Utils.uuid
# def self.uuid
#   RUBY_VERSION < '1.9' ? "_#{@@uuid_generator.generate}" : "_#{SecureRandom.uuid}"
# end

$  request_doc.uuid = request.uuid 
=> "_2179fe0b-198a-46ab-83da-a155128f8080"

$  root = request_doc.add_element "samlp:AuthnRequest", { "xmlns:samlp" => "urn:oasis:names:tc:SAML:2.0:protocol", "xmlns:saml" => "urn:oasis:names:tc:SAML:2.0:assertion" }
=> <samlp:AuthnRequest xmlns:samlp='urn:oasis:names:tc:SAML:2.0:protocol' xmlns:saml='urn:oasis:names:tc:SAML:2.0:assertion'/>

$  root.attributes['ID'] = request.uuid # 認証要求メッセージ毎にユニークなランダム文字列
=> "_2179fe0b-198a-46ab-83da-a155128f8080"

$ root.attributes['IssueInstant'] = time # 認証要求メッセージの発行日時
=> "2021-10-23T12:45:30Z"

$ root.attributes['Version'] = "2.0" # SAMLのバージョン
=> "2.0"

# root.attributes['Destination'] = settings.idp_sso_service_url unless settings.idp_sso_service_url.nil? or settings.idp_sso_service_url.empty?
# をまるっと実行するのは面白くないので1フレーズ毎に実行してみます。

$ settings.idp_sso_service_url
=> "https://auth.worksmobile.com/saml2/idp/your_group_name"

$ settings.idp_sso_service_url.nil? or settings.idp_sso_service_url.empty?
=> false

# Destinationは認証要求先
$ root.attributes['Destination'] = settings.idp_sso_service_url unless settings.idp_sso_service_url.nil? or settings.idp_sso_service_url.empty?
=> "https://auth.worksmobile.com/saml2/idp/your_group_name"

$ root.attributes['IsPassive'] = settings.passive unless settings.passive.nil? # ユーザーを関与させずに認証できる場合にのみこのユーザーを認証するかどうか
=> nil

# IdP側がSPにSamlResponseを送信する際に利用するSAML Binding
$ root.attributes['ProtocolBinding'] = settings.protocol_binding unless settings.protocol_binding.nil?
=> "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST"

# IdPに要求するユーザ属性グループのインデックスを指定
$ root.attributes["AttributeConsumingServiceIndex"] = settings.attributes_index unless settings.attributes_index.nil?

# IdPが以前のSecurity Contextを使用せず、必ずユーザーを直接的に認証するかどうかを指定
$ root.attributes['ForceAuthn'] = settings.force_authn unless settings.force_authn.nil?

$ settings.assertion_consumer_service_url != nil
=> true

# SPがSamlResponseを受け取るエンドポイントのURL
$ root.attributes["AssertionConsumerServiceURL"] = settings.assertion_consumer_service_url
=> "http://localhost:3000/saml/acs"

$ settings.sp_entity_id != nil
=> true

$ issuer = root.add_element "saml:Issuer" # SPのユニークなID
=> <saml:Issuer/>

$ issuer.text = settings.sp_entity_id
=> "http://localhost:3000/saml/metadata"

$ settings.name_identifier_value_requested != nil
=> false

$ settings.name_identifier_format != nil
=> true

# SamlResponseメッセージ内のユーザーの識別子に関するポリシー
$ root.add_element "samlp:NameIDPolicy", {
    # Might want to make AllowCreate a setting?
    "AllowCreate" => "true",
    "Format" => settings.name_identifier_format
}

# 電子メール アドレス形式で NameID 要求を発行
$ <samlp:NameIDPolicy AllowCreate='true' Format='urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress'/>

# authn_contextは必要な認証方法を指定
$ settings.authn_context || settings.authn_context_decl_ref
=> nil

これで一通りcreate_xml_documentメソッドの処理が追えました、、、

次にsign_documentメソッドを見てみます。

sign_document

def sign_document(document, settings)
  if settings.idp_sso_service_binding == Utils::BINDINGS[:post] && settings.security[:authn_requests_signed] && settings.private_key && settings.certificate
    private_key = settings.get_sp_key
    cert = settings.get_sp_cert
    document.sign_document(private_key, cert, settings.security[:signature_method], settings.security[:digest_method])
  end

  document
end

こちらもコンソールで実行しながら試してみます。

$ settings.idp_sso_service_binding
=> "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect"

$ OneLogin::RubySaml::Utils::BINDINGS[:post]
=> "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST"

$ settings.security[:authn_requests_signed]
=> false

$ settings.private_key && settings.certificate
=> nil

$ settings.idp_sso_service_binding == OneLogin::RubySaml::Utils::BINDINGS[:post] && settings.security[:authn_requests_signed] && settings.private_key && settings.certificate
=> false

もし上記の条件がtrueだった場合は、XMLSecurity::Documentのインスタンスに定義されているメソッドであるsign_documentを呼び出す実装になっていました。

create_authentication_xml_docメソッド内で呼び出されているメソッドを読み終えたので、create_paramsメソッドに戻ります。

create_params

# 一部のIdPでSPで開始されたシナリオでの属性値のシングルクォートに問題があるため
$ request_doc.context[:attribute_quote] = :quote if settings.double_quote_xml_attribute_values
=> nil

$ request = ""

$ request_doc.write(request)
=> [<?xml ... ?>,
 <samlp:AuthnRequest xmlns:samlp='urn:oasis:names:tc:SAML:2.0:protocol' xmlns:saml='urn:oasis:names:tc:SAML:2.0:assertion' IssueInstant='2021-10-23T12:45:30Z' Version='2.0' ID='_2179fe0b-198a-46ab-83da-a155128f8080' Destination='https://auth.worksmobile.com/saml2/idp/your_group_name' AssertionConsumerServiceURL='http://localhost:3000/saml/acs'> ... </>]

# FirefoxSAMLTracerなどの診断ツールとうまく連携するため
# https://github.com/onelogin/ruby-saml/pull/57
$ request = Zlib::Deflate.deflate(request, 9)[2..-5] if settings.compress_request
=> "}\x92MK\xC3@\x10\x86\xFFJn{\xCA\xC7\xA6\xAD\xC6%\t\x84\x16\xA1PEZ\xF5\xE0E\xA6\xC9\x94,\xD9\x8F\xB8\xB3\xB1\xFA\xEFMS\x84z\xB0\xD7\xE5y\xDEy\x99\xD9\x9C@\xAB^T\x83o\xCD\x16?\x06$\x1FTD\xD8\xBC\xB4fi\r\r\x1A\xDD\x0E\xDD\xA7\xAC\xF1e\xBB)X\xEB}/\xE2X\xD9\x1ATk\xC9\x8BY\x92$\xF1)%\x86\x9AX\xB0\x1A\x13\xA4\x81\x93~\x86i\xA4a\x8C\x8F\x8E\xD6u\xA4\xED^*\x8Cj\xAB''\x8De\xD3\xC7\x1DJ\x1A:\xCC,\xA5\xB2\x83#\xB0`\xBD*\xD8{\xCAo\xEF\x0E\x98\xECC~\x97A8\xBF\x81}\x98\xCD\x1A\b\x81/\x16<\xCD\x0EY\x92%#J4\xE0\xDA\x90\a\xE3\v\x96&)\x0Fy\x12\xA6\xB3g\x9E\x8A\xF9b\xAC\xF7\xC6\x82Wt4\x15J\xA3\xD1\xF8\xD2\xCA\x908\x8D/\xD8\xE0\x8C\xB0@\x92\x84\x01\x8D$|-v\xD5\xC3F\x8C\xB0\x80\xDF=\\*\xFDu\xA7w\xD6\xDB\xDA*V\xE6'ZL\xED\\yek\x1A=4\xE0!\x8F/\x85\xFC|\x97\xC7q\xC0z\xF5d\x95\xAC\xBF\x83J){\\:\x04\x8F\x05\xF3n@\x16\xDC[\xA7\xC1\xFF_\x89G|z\x91Mx\x98P\x81\x1A\xA4\xAA\x9A\xC6!\x11\x8B\xCB\xF3\xD4\xBF\x1F\xA0\xFC\x01"

# https://github.com/onelogin/ruby-saml/blob/master/lib/onelogin/ruby-saml/saml_message.rb#L128
$ base64_request = Base64.strict_encode64(request)
=> "fZJNS8NAEIb/ms54ysemrcYlCYQWoVBFWvXgRabJlCzZj7izsfrvTVOEerDX5XneeZnZnECrXlSDb80WPwYkH1RE6Ly0ZmkNDRrdDt2nrPFluylY630v4ljZGlRryYtZkiTxKSWGmliwGhOkgZN+hmmkYYyPjtZ1pO1eKoxqqycnjWXTxx1KGjrsLLWygyOwYL0q2HvKb+8OmOxDfpdBOL+BfZjNGgiBLxY8zQ5ZkiUjSjTg2pAH4wuWJikPeRKms2eeivlirPfGgld0NBVKo9H40sqQOI0v2OCMsECShAGNJHwtdtXDRoyggN89XCr9dad31tvaKlbmJ1pM7Vx5ZWsaPTTgIY8vhfx8l8dxwHr1ZJWsv4NKKXtcOgSPBfNuQBbcW6fB/1+JR3x6kU14mFCBGqSqmsYhEYvL89S/H6D8AQ=="

$ request_params = {"SAMLRequest" => base64_request}

$ request_params = {"SAMLRequest" => base64_request}
=> {"SAMLRequest"=>"fZJNS8NAEIb/ms54ysemrcYlCYQWoVBFWvXgRabJlCzZj7izsfrvTVOEerDX5XneeZnZnECrXlSDb80WPwYkH1RE6Ly0ZmkNDRrdDt2nrPFluylY630v4ljZGlRryYtZkiTxKSWGmliwGhOkgZN+hmmkYYyPjtZ1pO1eKoxqqycnjWXTxx1KGjrsLLWygyOwYL0q2HvKb+8OmOxDfpdBOL+BfZjNGgiBLxY8zQ5ZkiUjSjTg2pAH4wuWJikPeRKms2eeivlirPfGgld0NBVKo9H40sqQOI0v2OCMsECShAGNJHwtdtXDRoyggN89XCr9dad31tvaKlbmJ1pM7Vx5ZWsaPTTgIY8vhfx8l8dxwHr1ZJWsv4NKKXtcOgSPBfNuQBbcW6fB/1+JR3x6kU14mFCBGqSqmsYhEYvL89S/H6D8AQ=="}

$ settings.idp_sso_service_binding == OneLogin::RubySaml::Utils::BINDINGS[:redirect]
=> true

$ settings.security[:authn_requests_signed] && settings.private_key
=> false

$ settings.idp_sso_service_binding == OneLogin::RubySaml::Utils::BINDINGS[:redirect] && settings.security[:authn_requests_signed] && settings.private_key
=> false

$ params.each_pair do |key, value|
  request_params[key] = value.to_s
end
=> {}

ようやくcreate_paramsメソッドも読み終わりました。ざっくりの理解ですがSamlRequest用のパラメータを構築するための処理でしたね。

createメソッドの処理に戻ります。

create

$ params = request_params

$ params_prefix = (settings.idp_sso_service_url =~ /\?/) ? '&' : '?'
=> "?"

# SAMLRequest needs to be URL-encoded とのこと。
$ saml_request = CGI.escape(params.delete("SAMLRequest"))
=> "fQJNS8NADIb%2FSm57ysemrcYlCYQWoVBFDvXgRabJlCzZj7izsfrvTVOEerDX5XneeZnZnDCrCVAQb80WPwYkH1RE6Ly0ZmkNDRrdDt2nrPFluylY630v4ljZGlRryYtZkiTxKSWGmliwGhOkgZN%2BhmmkYYyPjtZ1pO1eKoxqqycnjWXTxx1KGjrsLLSygyOwMS0q2HvKb%2B8LmOxDfpdBOL%2BBfZjNGgiBLxY8zQ5ZkiUjSjTg2pAH4wuWJikPeRKms2eeivlirPfGgld0NBVKo9H40sqQOI0v2OCMsECShAGNJHwtdtXDRoyggN89XCr9dad31tvaKlbmJ1pM7Vx5ZWsaPTTgIY8vhfx8l8dxwHr1ZJWsv4NKKXtcOgSPBfNuQBbcW6fB%2F1%2BJR3x6kU14mFCBGqSqmsYhEYvL89S%2FH6D8AQ%3D%3C"

$ request_params = "#{params_prefix}SAMLRequest=#{saml_request}"
=> "?fQJNS8NADIb%2FSm57ysemrcYlCYQWoVBFDvXgRabJlCzZj7izsfrvTVOEerDX5XneeZnZnDCrCVAQb80WPwYkH1RE6Ly0ZmkNDRrdDt2nrPFluylY630v4ljZGlRryYtZkiTxKSWGmliwGhOkgZN%2BhmmkYYyPjtZ1pO1eKoxqqycnjWXTxx1KGjrsLLSygyOwMS0q2HvKb%2B8LmOxDfpdBOL%2BBfZjNGgiBLxY8zQ5ZkiUjSjTg2pAH4wuWJikPeRKms2eeivlirPfGgld0NBVKo9H40sqQOI0v2OCMsECShAGNJHwtdtXDRoyggN89XCr9dad31tvaKlbmJ1pM7Vx5ZWsaPTTgIY8vhfx8l8dxwHr1ZJWsv4NKKXtcOgSPBfNuQBbcW6fB%2F1%2BJR3x6kU14mFCBGqSqmsYhEYvL89S%2FH6D8AQ%3D%3C"

$ params.each_pair do |key, value|
  request_params << "&#{key.to_s}=#{CGI.escape(value.to_s)}"
end
=> {}

# もしidp_sso_service_urlがnilまたは空の場合は Invalid settings, idp_sso_service_url is not set! の例外が返る
$ settings.idp_sso_service_url.nil? or settings.idp_sso_service_url.empty?
=> false

$ @login_url = settings.idp_sso_service_url + request_params
=> "https://auth.worksmobile.com/saml2/idp/your_group_name?SAMLRequest=fQJNS8NADIb%2FSm57ysemrcYlCYQWoVBFDvXgRabJlCzZj7izsfrvTVOEerDX5XneeZnZnDCrCVAQb80WPwYkH1RE6Ly0ZmkNDRrdDt2nrPFluylY630v4ljZGlRryYtZkiTxKSWGmliwGhOkgZN%2BhmmkYYyPjtZ1pO1eKoxqqycnjWXTxx1KGjrsLLSygyOwMS0q2HvKb%2B8LmOxDfpdBOL%2BBfZjNGgiBLxY8zQ5ZkiUjSjTg2pAH4wuWJikPeRKms2eeivlirPfGgld0NBVKo9H40sqQOI0v2OCMsECShAGNJHwtdtXDRoyggN89XCr9dad31tvaKlbmJ1pM7Vx5ZWsaPTTgIY8vhfx8l8dxwHr1ZJWsv4NKKXtcOgSPBfNuQBbcW6fB%2F1%2BJR3x6kU14mFCBGqSqmsYhEYvL89S%2FH6D8AQ%3D%3C"

やっと読み終えた、、、

OneLogin::RubySaml::AuthRequestインスタンスのcreateメソッドは、SAMLRequest用のURLを返却するメソッドでした。(間でいろーんな処理を挟みつつ。)

もう一度controllerの処理を見ます。

def sso
  settings = Account.get_saml_settings(get_url_base)
  if settings.nil?
    render :action => :no_settings
    return
  end

  request = OneLogin::RubySaml::Authrequest.new

  redirect_to(request.create(settings))
end

ssoアクションsamlのsettingsがnilならno_settingsの画面をレンダリングし、settingsがあればrequest.create(settings)の処理で得られたURLへリダイレクトする処理でした。


まとめ

acsアクションのコードリーディングは別の記事にします。(すでに16000字越え)

SamlRequest生成のところだけでも知らない用語が大量にあり、読むのに大変時間がかかりました、、、

ただ、実行しつつわからない単語を調べつつ進めたことで、ただ「SSOできた!!」だけで終わらせるよりも遥かに効果があったと感じています。

SamlResponseの方も今回と同じくらいの文量になりそう、、、