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

こんにちは!kossyです!



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





環境
Ruby 2.6.8
Rails 6.0.4.1
MacOS Catalina



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

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

kossy-web-engineer.hatenablog.com




metadataとは?

そもそもmetadataとはなんでしょうか。

SAML メタデータ・ファイルには、 SAML 2.0 プロトコル・メッセージ交換で使用できるさまざまな SAML 権限に関する情報が含まれています。
このメタデータは、 ID プロバイダー・エンドポイントと証明書を識別し、 SAML 2.0 メッセージ交換を保護します。

出典: SAML 2.0 メタデータ・ファイルの定義

SAMLパーティ間で設定情報を表現および共有するXMLスキーマを定義
SPがIdPを利用するための情報を記述して、IdPとSPの信頼関係を構築できる。
・エンティティのサポートされているSAMLバインディング
・運用上の役割(IDP、SPなど)
・識別子情報、サポートID属性
・および暗号化と署名のための鍵情報

出典: SAMLの仕様を読む。 - マイクロソフト系技術情報 Wiki

SAML認証では基本的にIdPにServiceProviderの情報を登録する必要があるのですが、この登録時にmetadata(= SP Issuer、EntityIDとも)を取得するエンドポイントを登録します。

LINE WORKSの場合だと以下の画像のように登録します。

f:id:kossy-web-engineer:20211031082809p:plain

より詳しいMetadataの仕様について拙著では解説しませんので、興味のある方は以下のPDFを参考にしてみてください。

https://docs.oasis-open.org/security/saml/v2.0/saml-metadata-2.0-os.pdf

metadataアクション

コードリーディングに移ります。

def metadata
  settings = Account.get_saml_settings(get_url_base)
  meta = OneLogin::RubySaml::Metadata.new
  render :xml => meta.generate(settings, true)
end

metadataアクション自体は3行のコードです。

まずはOneLogin::RubySaml::Metadataのinitializeメソッドを見に行ってみます。と思ったらinitializeメソッドが定義されてなかった、、、

なので、generateメソッドを読んでみます。


OneLogin::RubySaml::Metadata#generate

def generate(settings, pretty_print=false, valid_until=nil, cache_duration=nil)
  meta_doc = XMLSecurity::Document.new
  add_xml_declaration(meta_doc)
  root = add_root_element(meta_doc, settings, valid_until, cache_duration)
  sp_sso = add_sp_sso_element(root, settings)
  add_sp_certificates(sp_sso, settings)
  add_sp_service_elements(sp_sso, settings)
  add_extras(root, settings)
  embed_signature(meta_doc, settings)
  output_xml(meta_doc, pretty_print)
end

最終的な返り値はXMLかと思われますが、いろいろなメソッドが呼ばれてますので、一つ一つ見ていきます。


add_xml_declaration(meta_doc)

def add_xml_declaration(meta_doc)
  meta_doc << REXML::XMLDecl.new('1.0', 'UTF-8')
end

引数のmeta_docにXMLDeclarationを追加するメソッドでした。

# rails c

$ meta_doc = XMLSecurity::Document.new

$ meta_doc << REXML::XMLDecl.new('1.0', 'UTF-8')
=>  <UNDEFINED> ... </>

www.weblio.jp


add_root_element

def add_root_element(meta_doc, settings, valid_until, cache_duration)
  namespaces = {
      "xmlns:md" => "urn:oasis:names:tc:SAML:2.0:metadata"
  }

  if settings.attribute_consuming_service.configured?
    namespaces["xmlns:saml"] = "urn:oasis:names:tc:SAML:2.0:assertion"
  end

  root = meta_doc.add_element("md:EntityDescriptor", namespaces)
  root.attributes["ID"] = OneLogin::RubySaml::Utils.uuid
  root.attributes["entityID"] = settings.sp_entity_id if settings.sp_entity_id
  root.attributes["validUntil"] = valid_until.strftime('%Y-%m-%dT%H:%M:%S%z') if valid_until
  root.attributes["cacheDuration"] = "PT" + cache_duration.to_s + "S" if cache_duration
  root
end

SAMLに必要なRootElementを付与するメソッドでした。


add_sp_sso_element

def add_sp_sso_element(root, settings)
  root.add_element "md:SPSSODescriptor", {
      "protocolSupportEnumeration" => "urn:oasis:names:tc:SAML:2.0:protocol",
      "AuthnRequestsSigned" => settings.security[:authn_requests_signed],
      "WantAssertionsSigned" => settings.security[:want_assertions_signed],
  }
end

SPSSODescriptorの説明については以下ブログが参考になりました。

blog.cybozu.io


AuthnRequestsSignedについては以下の記事が参考になりました。

AuthnRequestsSigned

このサービス・プロバイダーによって送信される <samlp:AuthnRequest> メッセージに署名するかどうかを指定します。

出典: SAML Web SSO 2.0 認証 (samlWebSso20)

この変更が最初に加わったのはこのPRのようです。(当初はDefaultでfalseで外部からカスタマイズができない設定だったんですね。)

github.com



wantAssertionsSignedについては以下の記述。

wantAssertionsSigned

このサービス・プロバイダーが受信する <saml:Assertion> エレメントが、アサーションに署名する Signature エレメントを含む必要があるかを指定します。

出典: SAML Web SSO 2.0 認証 (samlWebSso20)

add_sp_certificates

# Add KeyDescriptor if messages will be signed / encrypted
# with SP certificate, and new SP certificate if any

# メッセージがSP証明書で署名/暗号化される場合はKeyDescriptorを追加し、
# 存在する場合は新しいSP証明書を追加します

def add_sp_certificates(sp_sso, settings)
  cert = settings.get_sp_cert
  cert_new = settings.get_sp_cert_new

  for sp_cert in [cert, cert_new]
    if sp_cert
      cert_text = Base64.encode64(sp_cert.to_der).gsub("\n", '')
      kd = sp_sso.add_element "md:KeyDescriptor", { "use" => "signing" }
      ki = kd.add_element "ds:KeyInfo", {"xmlns:ds" => "http://www.w3.org/2000/09/xmldsig#"}
      xd = ki.add_element "ds:X509Data"
      xc = xd.add_element "ds:X509Certificate"
      xc.text = cert_text

      if settings.security[:want_assertions_encrypted]
        kd2 = sp_sso.add_element "md:KeyDescriptor", { "use" => "encryption" }
        ki2 = kd2.add_element "ds:KeyInfo", {"xmlns:ds" => "http://www.w3.org/2000/09/xmldsig#"}
        xd2 = ki2.add_element "ds:X509Data"
        xc2 = xd2.add_element "ds:X509Certificate"
        xc2.text = cert_text
      end
    end
  end

  sp_sso
end

正直なんのこっちゃなのでコンソールで実行してみます。

$ cert = settings.get_sp_cert
=> nil

$ cert_new = settings.get_sp_cert_new
=> nil

$   for sp_cert in [cert, cert_new]
    if sp_cert
      cert_text = Base64.encode64(sp_cert.to_der).gsub("\n", '')
      kd = sp_sso.add_element "md:KeyDescriptor", { "use" => "signing" }
      ki = kd.add_element "ds:KeyInfo", {"xmlns:ds" => "http://www.w3.org/2000/09/xmldsig#"}
      xd = ki.add_element "ds:X509Data"
      xc = xd.add_element "ds:X509Certificate"
      xc.text = cert_text

      if settings.security[:want_assertions_encrypted]
        kd2 = sp_sso.add_element "md:KeyDescriptor", { "use" => "encryption" }
        ki2 = kd2.add_element "ds:KeyInfo", {"xmlns:ds" => "http://www.w3.org/2000/09/xmldsig#"}
        xd2 = ki2.add_element "ds:X509Data"
        xc2 = xd2.add_element "ds:X509Certificate"
        xc2.text = cert_text
      end
    end
  end

=> [nil, nil]

私の環境ではcertもcert_newもnilでした。

理由はsettingsオブジェクトのcertificate属性に値を入れていないからでした。


add_sp_service_elements

def add_sp_service_elements(sp_sso, settings)
  if settings.single_logout_service_url
    sp_sso.add_element "md:SingleLogoutService", {
        "Binding" => settings.single_logout_service_binding,
        "Location" => settings.single_logout_service_url,
        "ResponseLocation" => settings.single_logout_service_url
    }
  end

  if settings.name_identifier_format
    nameid = sp_sso.add_element "md:NameIDFormat"
    nameid.text = settings.name_identifier_format
  end

  if settings.assertion_consumer_service_url
    sp_sso.add_element "md:AssertionConsumerService", {
        "Binding" => settings.assertion_consumer_service_binding,
        "Location" => settings.assertion_consumer_service_url,
        "isDefault" => true,
        "index" => 0
    }
  end

  if settings.attribute_consuming_service.configured?
    sp_acs = sp_sso.add_element "md:AttributeConsumingService", {
      "isDefault" => "true",
      "index" => settings.attribute_consuming_service.index
    }
    srv_name = sp_acs.add_element "md:ServiceName", {
      "xml:lang" => "en"
    }
    srv_name.text = settings.attribute_consuming_service.name
    settings.attribute_consuming_service.attributes.each do |attribute|
      sp_req_attr = sp_acs.add_element "md:RequestedAttribute", {
        "NameFormat" => attribute[:name_format],
        "Name" => attribute[:name],
        "FriendlyName" => attribute[:friendly_name],
        "isRequired" => attribute[:is_required] || false
      }
      unless attribute[:attribute_value].nil?
        Array(attribute[:attribute_value]).each do |value|
          sp_attr_val = sp_req_attr.add_element "saml:AttributeValue"
          sp_attr_val.text = value.to_s
        end
      end
    end
  end

  # With OpenSSO, it might be required to also include
  #  <md:RoleDescriptor xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:query="urn:oasis:names:tc:SAML:metadata:ext:query" xsi:type="query:AttributeQueryDescriptorType" protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol"/>
  #  <md:XACMLAuthzDecisionQueryDescriptor WantAssertionsSigned="false" protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol"/>

  sp_sso
end

ServiceProviderの情報をXMLに適宜追加するメソッドですね。


add_extras

# can be overridden in subclass
def add_extras(root, _settings)
  root
end

サブクラスでオーバーライドできるとのことでした。

このメソッドが加わったのは以下のPRです。

github.com

Conversationを見ると独自で追加したいメタデータを定義する用途で使うメソッドっぽいです。


embed_signature

def embed_signature(meta_doc, settings)
  return unless settings.security[:metadata_signed]

  private_key = settings.get_sp_key
  cert = settings.get_sp_cert
  return unless private_key && cert

  meta_doc.sign_document(private_key, cert, settings.security[:signature_method], settings.security[:digest_method])
end


XMLに署名を埋め込むメソッドですね。こちらはmetadata_signed属性やsp_key、certificate等がないと処理がスキップされるみたいです。


最後のoutput_xmlは引数を元にXMLを出力するメソッドでした。


まとめ

metadataの仕様が分からなさすぎて、ただ処理を追うだけになってしまい「なんでこの処理が必要なのか」までは理解ができませんでした。。。

次はmetadataの仕様を自分なりに調べてまとめた記事になりそう、、、


大いに参考にさせていただいたサイト

素晴らしいコンテンツの提供、誠にありがとうございます。

SAML認証ができるまで - Cybozu Inside Out | サイボウズエンジニアのブログ
SAML Web SSO 2.0 認証 (samlWebSso20)