こんにちは!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の方も今回と同じくらいの文量になりそう、、、
大いに参考にさせていただいた記事
素晴らしいコンテンツの提供、誠にありがとうございます。
SAML認証ができるまで - Cybozu Inside Out | サイボウズエンジニアのブログ
Azure シングル サインオンの SAML プロトコル - Microsoft identity platform | Microsoft Docs
opensaml - SAML 2.0 IsPassive option - Stack Overflow