From ae314cc29d5774ac4d319764066e86f647fa3905 Mon Sep 17 00:00:00 2001 From: johnnyshields <27655+johnnyshields@users.noreply.github.com> Date: Tue, 18 Mar 2025 15:56:24 +0900 Subject: [PATCH 1/7] YOLO refactor of SP settings --- CHANGELOG.md | 2 +- README.md | 6 +- UPGRADING.md | 4 +- lib/ruby_saml.rb | 33 ++- lib/ruby_saml/deprecated_messages.rb | 65 +++++ lib/ruby_saml/{ => old}/authrequest.rb | 0 lib/ruby_saml/{ => old}/logoutrequest.rb | 0 lib/ruby_saml/{ => old}/slo_logoutresponse.rb | 0 lib/ruby_saml/sp/builders/authn_request.rb | 76 ++++++ lib/ruby_saml/sp/builders/logout_request.rb | 57 +++++ lib/ruby_saml/sp/builders/logout_response.rb | 48 ++++ lib/ruby_saml/sp/builders/message_builder.rb | 223 ++++++++++++++++++ test/response_test.rb | 6 +- test/xml_test.rb | 4 +- 14 files changed, 503 insertions(+), 21 deletions(-) create mode 100644 lib/ruby_saml/deprecated_messages.rb rename lib/ruby_saml/{ => old}/authrequest.rb (100%) rename lib/ruby_saml/{ => old}/logoutrequest.rb (100%) rename lib/ruby_saml/{ => old}/slo_logoutresponse.rb (100%) create mode 100644 lib/ruby_saml/sp/builders/authn_request.rb create mode 100644 lib/ruby_saml/sp/builders/logout_request.rb create mode 100644 lib/ruby_saml/sp/builders/logout_response.rb create mode 100644 lib/ruby_saml/sp/builders/message_builder.rb diff --git a/CHANGELOG.md b/CHANGELOG.md index f6165e3e..e74ebab5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -114,7 +114,7 @@ * Fix ruby 1.8.7 incompatibilities ### 1.10.0 (Mar 21, 2019) -* Add Subject support on AuthNRequest to allow SPs provide info to the IdP about the user to be authenticated +* Add Subject support on AuthnRequest to allow SPs provide info to the IdP about the user to be authenticated * Improves IdpMetadataParser to allow parse multiple IDPSSODescriptors * Improves format_cert method to accept certs with /\x0d/ * Forces nokogiri >= 1.8.2 when possible diff --git a/README.md b/README.md index 50d54705..2b52f0a9 100644 --- a/README.md +++ b/README.md @@ -526,7 +526,7 @@ pp(response.attributes.fetch(/givenname/)) # => ["usersName"] ``` -The `saml:AuthnContextClassRef` of the AuthNRequest can be provided by `settings.authn_context`; possible values are described at [SAMLAuthnCxt]. The comparison method can be set using `settings.authn_context_comparison` parameter. Possible values include: 'exact', 'better', 'maximum' and 'minimum' (default value is 'exact'). +The `saml:AuthnContextClassRef` of the AuthnRequest can be provided by `settings.authn_context`; possible values are described at [SAMLAuthnCxt]. The comparison method can be set using `settings.authn_context_comparison` parameter. Possible values include: 'exact', 'better', 'maximum' and 'minimum' (default value is 'exact'). To add a `saml:AuthnContextDeclRef`, define `settings.authn_context_decl_ref`. In a SP-initiated flow, the SP can indicate to the IdP the subject that should be authenticated. This is done by defining the `settings.name_identifier_value_requested` before @@ -618,7 +618,7 @@ settings.private_key = "PRIVATE KEY TEXT WITH BEGIN/END HEADER AND FOOTER" Next, you may specify the specific SP SAML messages you would like to sign: ```ruby -settings.security[:authn_requests_signed] = true # Enable signature on AuthNRequest +settings.security[:authn_requests_signed] = true # Enable signature on AuthnRequest settings.security[:logout_requests_signed] = true # Enable signature on Logout Request settings.security[:logout_responses_signed] = true # Enable signature on Logout Response ``` @@ -918,7 +918,7 @@ The `attribute_value` option additionally accepts an array of possible values. ### SP-Originated Message IDs -Ruby SAML automatically generates message IDs for SP-originated messages (AuthNRequest, etc.) +Ruby SAML automatically generates message IDs for SP-originated messages (AuthnRequest, etc.) By default, this is a UUID prefixed by the `_` character, for example `"_ea8b5fdf-0a71-4bef-9f87-5406ee746f5b"`. To override this behavior, you may set `settings.sp_uuid_prefix` to a string of your choice. Note that the SAML specification requires that this type (`xsd:ID`) be an diff --git a/UPGRADING.md b/UPGRADING.md index c322d34e..9d5e2595 100644 --- a/UPGRADING.md +++ b/UPGRADING.md @@ -134,7 +134,7 @@ this prefix is now set using `settings.sp_uuid_prefix`: # Change the default prefix from `_` to `my_id_` settings.sp_uuid_prefix = 'my_id_' -# Create the AuthNRequest message +# Create the AuthnRequest message request = RubySaml::Authrequest.new request.create(settings) request.uuid #=> "my_id_a1b3c5d7-9f1e-3d5c-7b1a-9f1e3d5c7b1a" @@ -252,7 +252,7 @@ Version `1.10.1` improves Ruby 1.8.7 support. ## Upgrading from 1.9.0 to 1.10.0 Version `1.10.0` improves IdpMetadataParser to allow parse multiple IDPSSODescriptor, -Add Subject support on AuthNRequest to allow SPs provide info to the IdP about the user +Add Subject support on AuthnRequest to allow SPs provide info to the IdP about the user to be authenticated and updates the format_cert method to accept certs with /\x0d/ ## Upgrading from 1.8.0 to 1.9.0 diff --git a/lib/ruby_saml.rb b/lib/ruby_saml.rb index b481a2ba..97fceee0 100644 --- a/lib/ruby_saml.rb +++ b/lib/ruby_saml.rb @@ -1,21 +1,34 @@ # frozen_string_literal: true +require 'cgi' +require 'zlib' +require 'base64' +require 'time' +require 'nokogiri' + require 'ruby_saml/logging' require 'ruby_saml/xml' -require 'ruby_saml/saml_message' -require 'ruby_saml/authrequest' -require 'ruby_saml/logoutrequest' -require 'ruby_saml/logoutresponse' -require 'ruby_saml/attributes' -require 'ruby_saml/slo_logoutrequest' -require 'ruby_saml/slo_logoutresponse' -require 'ruby_saml/response' require 'ruby_saml/settings' -require 'ruby_saml/attribute_service' + +require 'ruby_saml/sp/builders/message_builder' +require 'ruby_saml/sp/builders/authn_request' +require 'ruby_saml/sp/builders/logout_request' +require 'ruby_saml/sp/builders/logout_response' +require 'ruby_saml/metadata' + +# TODO: Extract errors to have common base class +require 'ruby_saml/setting_error' require 'ruby_saml/http_error' require 'ruby_saml/validation_error' -require 'ruby_saml/metadata' + +require 'ruby_saml/attribute_service' +require 'ruby_saml/attributes' +require 'ruby_saml/saml_message' +require 'ruby_saml/response' +require 'ruby_saml/logoutresponse' +require 'ruby_saml/slo_logoutrequest' require 'ruby_saml/idp_metadata_parser' + require 'ruby_saml/pem_formatter' require 'ruby_saml/utils' require 'ruby_saml/version' diff --git a/lib/ruby_saml/deprecated_messages.rb b/lib/ruby_saml/deprecated_messages.rb new file mode 100644 index 00000000..a5f95e82 --- /dev/null +++ b/lib/ruby_saml/deprecated_messages.rb @@ -0,0 +1,65 @@ + + +module RubySaml + module DeprecatedMessageMixin + def warn_deprecated_message + klass = self.class + warn "[DEPRECATION] #{klass} is deprecated. Please use #{klass.superclass} instead." + end + end + + class Authrequest < RubySaml::Messages::Sp::AuthnRequest + include DeprecatedMessageMixin + + def initialize + warn_deprecated_message + super + end + end + + class Logoutrequest < RubySaml::Messages::Sp::LogoutRequest + include DeprecatedMessageMixin + + def initialize + warn_deprecated_message + super + end + end + + class SloLogoutresponse < RubySaml::Messages::Sp::LogoutResponse + include DeprecatedMessageMixin + + def initialize + warn_deprecated_message + super + end + end + + # class Response < RubySaml::Messages::Idp::Response + # include DeprecatedMessageMixin + # + # def initialize(...) + # warn_deprecated_message + # super + # end + # end + # + # + # class Logoutresponse < RubySaml::Messages::Idp::LogoutResponse + # include DeprecatedMessageMixin + # + # def initialize(...) + # warn_deprecated_message + # super + # end + # end + # + # class SloLogoutrequest < RubySaml::Messages::Idp::LogoutRequest + # include DeprecatedMessageMixin + # + # def initialize(...) + # warn_deprecated_message + # super + # end + # end +end diff --git a/lib/ruby_saml/authrequest.rb b/lib/ruby_saml/old/authrequest.rb similarity index 100% rename from lib/ruby_saml/authrequest.rb rename to lib/ruby_saml/old/authrequest.rb diff --git a/lib/ruby_saml/logoutrequest.rb b/lib/ruby_saml/old/logoutrequest.rb similarity index 100% rename from lib/ruby_saml/logoutrequest.rb rename to lib/ruby_saml/old/logoutrequest.rb diff --git a/lib/ruby_saml/slo_logoutresponse.rb b/lib/ruby_saml/old/slo_logoutresponse.rb similarity index 100% rename from lib/ruby_saml/slo_logoutresponse.rb rename to lib/ruby_saml/old/slo_logoutresponse.rb diff --git a/lib/ruby_saml/sp/builders/authn_request.rb b/lib/ruby_saml/sp/builders/authn_request.rb new file mode 100644 index 00000000..cd9a18c9 --- /dev/null +++ b/lib/ruby_saml/sp/builders/authn_request.rb @@ -0,0 +1,76 @@ +# frozen_string_literal: true + +module RubySaml + module Sp + module Builders + # SAML AuthnRequest builder (SSO, SP-initiated) + class AuthnRequest < MessageBuilder + alias_method :request_id, :uuid + + def create(settings, old_params = {}, relay_state: nil) + super + end + + private + + def message_flow + :sso + end + + # Build the XML document + def create_xml(settings) + time = Time.now.utc.strftime("%Y-%m-%dT%H:%M:%SZ") + + root_attributes = { + 'xmlns:samlp' => RubySaml::XML::NS_PROTOCOL, + 'xmlns:saml' => RubySaml::XML::NS_ASSERTION, + 'ID' => uuid, + 'IssueInstant' => time, + 'Version' => '2.0', + 'Destination' => settings.idp_sso_service_url, + 'IsPassive' => settings.passive, + 'ProtocolBinding' => settings.protocol_binding, + 'AttributeConsumingServiceIndex' => settings.attributes_index, + 'ForceAuthn' => settings.force_authn, + 'AssertionConsumerServiceURL' => settings.assertion_consumer_service_url + } + + build_message(settings, 'AuthnRequest', root_attributes, :authn_requests_signed) do |xml| + # Add Issuer + xml['saml'].Issuer(settings.sp_entity_id) if settings.sp_entity_id + + # Add Subject if requested + if settings.name_identifier_value_requested + xml['saml'].Subject do + nameid_attrs = {} + nameid_attrs['Format'] = settings.name_identifier_format if settings.name_identifier_format + xml['saml'].NameID(settings.name_identifier_value_requested, nameid_attrs) + xml['saml'].SubjectConfirmation(Method: 'urn:oasis:names:tc:SAML:2.0:cm:bearer') + end + end + + # Add NameIDPolicy if format is specified + if settings.name_identifier_format + xml['samlp'].NameIDPolicy(AllowCreate: 'true', Format: settings.name_identifier_format) + end + + # Add RequestedAuthnContext if needed + if settings.authn_context || settings.authn_context_decl_ref + comparison = settings.authn_context_comparison || 'exact' + + xml['samlp'].RequestedAuthnContext(Comparison: comparison) do + Array(settings.authn_context).each do |context_class| + xml['saml'].AuthnContextClassRef(context_class) + end + + Array(settings.authn_context_decl_ref).each do |decl_ref| + xml['saml'].AuthnContextDeclRef(decl_ref) + end + end + end + end + end + end + end + end +end diff --git a/lib/ruby_saml/sp/builders/logout_request.rb b/lib/ruby_saml/sp/builders/logout_request.rb new file mode 100644 index 00000000..61717d91 --- /dev/null +++ b/lib/ruby_saml/sp/builders/logout_request.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +module RubySaml + module Sp + module Builders + # SAML LogoutRequest builder (SLO, SP-initiated) + class LogoutRequest < MessageBuilder + alias_method :request_id, :uuid + + def create(settings, old_params = {}, relay_state: nil) + super + end + + private + + def message_flow + :slo + end + + # Build the XML document + def create_xml(settings) + time = Time.now.utc.strftime('%Y-%m-%dT%H:%M:%SZ') + + root_attributes = { + 'xmlns:samlp' => RubySaml::XML::NS_PROTOCOL, + 'xmlns:saml' => RubySaml::XML::NS_ASSERTION, + 'ID' => uuid, + 'IssueInstant' => time, + 'Version' => '2.0', + 'Destination' => settings.idp_slo_service_url + } + + build_message(settings, 'LogoutRequest', root_attributes, :logout_requests_signed) do |xml| + # Add Issuer + xml['saml'].Issuer(settings.sp_entity_id) if settings.sp_entity_id + + # Add NameID + if settings.name_identifier_value + nameid_attrs = { + 'NameQualifier' => settings.idp_name_qualifier, + 'SPNameQualifier' => settings.sp_name_qualifier, + 'Format' => settings.name_identifier_format + } + xml['saml'].NameID(settings.name_identifier_value, clean_attributes(nameid_attrs)) + else + xml['saml'].NameID(RubySaml::Utils.uuid, + 'Format' => 'urn:oasis:names:tc:SAML:2.0:nameid-format:transient') + end + + # Add SessionIndex if provided + xml['samlp'].SessionIndex(settings.sessionindex) if settings.sessionindex + end + end + end + end + end +end diff --git a/lib/ruby_saml/sp/builders/logout_response.rb b/lib/ruby_saml/sp/builders/logout_response.rb new file mode 100644 index 00000000..b0d265fb --- /dev/null +++ b/lib/ruby_saml/sp/builders/logout_response.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +module RubySaml + module Sp + module Builders + # SAML LogoutResponse builder (SLO, IdP-initiated) + class LogoutResponse < MessageBuilder + alias_method :response_id, :uuid + + private + + def message_flow + :slo + end + + # Build the XML document + def create_xml(settings, request_id: nil, status_message: nil, status_code: nil) + time = Time.now.utc.strftime('%Y-%m-%dT%H:%M:%SZ') + + # Default values if not provided + status_code ||= 'urn:oasis:names:tc:SAML:2.0:status:Success' + status_message ||= 'Successfully Signed Out' + + root_attributes = { + 'xmlns:samlp' => RubySaml::XML::NS_PROTOCOL, + 'xmlns:saml' => RubySaml::XML::NS_ASSERTION, + 'ID' => uuid, + 'IssueInstant' => time, + 'Version' => '2.0', + 'InResponseTo' => request_id, + 'Destination' => service_url(settings, :slo_response) + }.compact + + build_message(settings, 'LogoutResponse', root_attributes, :logout_responses_signed) do |xml| + # Add Issuer + xml['saml'].Issuer(settings.sp_entity_id) if settings.sp_entity_id + + # Add Status + xml['samlp'].Status do + xml['samlp'].StatusCode(Value: status_code) + xml['samlp'].StatusMessage(status_message) + end + end + end + end + end + end +end diff --git a/lib/ruby_saml/sp/builders/message_builder.rb b/lib/ruby_saml/sp/builders/message_builder.rb new file mode 100644 index 00000000..f7282dce --- /dev/null +++ b/lib/ruby_saml/sp/builders/message_builder.rb @@ -0,0 +1,223 @@ +# frozen_string_literal: true + +module RubySaml + module Messages + # Base builder for SP-initiated SAML messages + class MessageBuilder < Message + attr_accessor :uuid + + # Generate a signed XML document + # def build_message(settings, root_name, attributes, security_option, &block) + # document = create_xml_document(root_name, attributes, &block) + # sign_document(document, settings, security_option) + # end + # + # # Create URL parameters including signatures if needed + # def build_params(settings, xml_doc, relay_state, param_name, security_option, binding_type) + # binding_redirect = binding_type == Utils::BINDINGS[:redirect] + # create_params(settings, xml_doc, binding_redirect, relay_state, security_option, param_name) + # end + # + # # Build the final URL + # def build_url(settings, params, service_url, param_name) + # create_url(settings, params, service_url, param_name) + # end + + # Create the LogoutResponse + def create(settings, + old_request_id = nil, + old_status_message = nil, + old_relay_state = nil, + old_status_code = nil, + relay_state: nil, + request_id: nil, + status_code: nil, + status_message: nil) + deprecate_positional_args(old_request_id, old_status_message, old_relay_state, old_status_code) + + @uuid = generate_uuid(settings) + relay_state = process_relay_state(params) + + + + service_url = service_url(settings, :slo_response) + raise SettingsError.new('Missing IdP SLO service URL') if service_url.nil? || service_url.empty? + + + + xml_doc = create_logout_response_xml(settings, request_id, logout_message, status_code) + + binding = binding_type(settings, :slo) + response_params = build_params( + settings, xml_doc, relay_state, "SAMLResponse", :logout_responses_signed, binding + ) + + @logout_url = build_url(settings, response_params, service_url, "SAMLResponse") + end + + # Create the LogoutRequest + def create(settings, + old_params = {}, + relay_state: nil) + # @uuid = generate_uuid(settings) + # relay_state = process_relay_state(params) + + + + + service_url = service_url(settings, :slo) + raise SettingsError.new("Missing IdP SLO service URL") if service_url.nil? || service_url.empty? + + + + + xml_doc = create_xml(settings) + + binding = binding_type(settings, :slo) + request_params = build_params( + settings, xml_doc, relay_state, "SAMLRequest", :logout_requests_signed, binding + ) + + @logout_url = build_url(settings, request_params, service_url, "SAMLRequest") + end + + def deprecate_positional_args(old_request_id, old_status_message, old_relay_state, old_status_code) + return if old_request_id.nil? && old_status_message.nil? && old_relay_state.nil? && old_status_code.nil? + + warn 'DEPRECATION WARNING: Positional arguments for RubySaml::Messages::MessageBuilder#create are deprecated. ' \ + 'Please use keyword arguments instead.' + end + + # Create the AuthnRequest + def create(settings, + old_params = {}, + relay_state: nil) + # @uuid = generate_uuid(settings) + # relay_state = process_relay_state(params) + + + + + service_url = service_url(settings, :sso) + raise SettingsError.new("Missing IdP SSO service URL") if service_url.nil? || service_url.empty? + + + + + xml_doc = create_authn_request_xml(settings) + + binding = binding_type(settings, :sso) + request_params = build_params( + settings, xml_doc, relay_state, 'SAMLRequest', :authn_requests_signed, binding + ) + + @login_url = build_url(settings, request_params, service_url, "SAMLRequest") + end + + private + + + + # Get the service URL from settings based on type + def service_url(settings, type) + case type + when :sso then settings.idp_sso_service_url + when :slo then settings.idp_slo_service_url + when :slo_response then settings.idp_slo_response_service_url || settings.idp_slo_service_url + else nil + end + end + + # Create URL with SAML parameters + def create_url(params, service_url, param_name) + raise ValidationError.new("Service URL cannot be nil") if service_url.nil? || service_url.empty? + + params_prefix = /\?/.match?(service_url) ? '&' : '?' + param_value = CGI.escape(params.delete(param_name)) + url_params = +"#{params_prefix}#{param_name}=#{param_value}" + + params.each_pair do |key, value| + url_params << "&#{key}=#{CGI.escape(value.to_s)}" + end + + service_url + url_params + end + + # Process relay state from params + def process_relay_state(params) + relay_state = params[:RelayState] || params['RelayState'] + + if relay_state.nil? + params.delete(:RelayState) + params.delete('RelayState') + end + + relay_state + end + + # Build signature parameters + def build_signature_params(settings, base64_data, relay_state, binding_redirect, security_option) + params = {} + sp_signing_key = settings.get_sp_signing_key + + if binding_redirect && settings.security[security_option] && sp_signing_key + params['SigAlg'] = settings.get_sp_signature_method + url_string = RubySaml::Utils.build_query( + type: @request_type, + data: base64_data, + relay_state: relay_state, + sig_alg: params['SigAlg'] + ) + sign_algorithm = RubySaml::XML.hash_algorithm(settings.get_sp_signature_method) + signature = sp_signing_key.sign(sign_algorithm.new, url_string) + params['Signature'] = Base64.strict_encode64(signature) + end + + params + end + + # Create parameters for SAML request/response + def create_params(settings, xml_doc, binding_redirect, relay_state, security_option, param_name) + request_doc = xml_doc + message = request_doc.to_xml(save_with: Nokogiri::XML::Node::SaveOptions::AS_XML) + + Logging.debug "Created #{param_name}: #{message}" + + @request_type = param_name + base64_message = RubySaml::XML::Decoder.encode_message(message, compress: binding_redirect) + message_params = { param_name => base64_message } + + signature_params = build_signature_params( + settings, + base64_message, + relay_state, + binding_redirect, + security_option + ) + + params = {} + params.merge!(signature_params) + + params.each_pair do |key, value| + message_params[key] = value.to_s + end + + message_params + end + + # Determine the binding type from settings + def binding_type(settings, type = :service) + case type + when :sso then settings.idp_sso_service_binding + when :slo then settings.idp_slo_service_binding + else nil + end + end + + # Generate a UUID + def generate_uuid(settings) + RubySaml::Utils.generate_uuid(settings.sp_uuid_prefix) + end + end + end +end diff --git a/test/response_test.rb b/test/response_test.rb index 1f48a95c..e5e4e348 100644 --- a/test/response_test.rb +++ b/test/response_test.rb @@ -270,7 +270,7 @@ def generate_audience_error(expected, actual) opts[:settings] = settings opts[:matches_request_id] = "invalid_request_id" response_valid_signed = RubySaml::Response.new(response_document_valid_signed, opts) - error_msg = "The InResponseTo of the Response: _fc4a34b0-7efb-012e-caae-782bcb13bb38, does not match the ID of the AuthNRequest sent by the SP: invalid_request_id" + error_msg = "The InResponseTo of the Response: _fc4a34b0-7efb-012e-caae-782bcb13bb38, does not match the ID of the AuthnRequest sent by the SP: invalid_request_id" assert_raises(RubySaml::ValidationError, error_msg) do response_valid_signed.is_valid? end @@ -429,7 +429,7 @@ def generate_audience_error(expected, actual) opts[:matches_request_id] = "invalid_request_id" response_valid_signed = RubySaml::Response.new(response_document_valid_signed, opts) response_valid_signed.is_valid? - assert_includes response_valid_signed.errors, "The InResponseTo of the Response: _fc4a34b0-7efb-012e-caae-782bcb13bb38, does not match the ID of the AuthNRequest sent by the SP: invalid_request_id" + assert_includes response_valid_signed.errors, "The InResponseTo of the Response: _fc4a34b0-7efb-012e-caae-782bcb13bb38, does not match the ID of the AuthnRequest sent by the SP: invalid_request_id" end it "return false when there is no valid audience" do @@ -685,7 +685,7 @@ def generate_audience_error(expected, actual) it "return false when the inResponseTo value does not match the Request ID" do response = RubySaml::Response.new(response_document_valid_signed, settings: settings, matches_request_id: "invalid_request_id") assert !response.send(:validate_in_response_to) - assert_includes response.errors, "The InResponseTo of the Response: _fc4a34b0-7efb-012e-caae-782bcb13bb38, does not match the ID of the AuthNRequest sent by the SP: invalid_request_id" + assert_includes response.errors, "The InResponseTo of the Response: _fc4a34b0-7efb-012e-caae-782bcb13bb38, does not match the ID of the AuthnRequest sent by the SP: invalid_request_id" end end diff --git a/test/xml_test.rb b/test/xml_test.rb index 8c947ad8..dfe0ae1e 100644 --- a/test/xml_test.rb +++ b/test/xml_test.rb @@ -256,7 +256,7 @@ class XmlTest < Minitest::Test settings end - it "signs an AuthNRequest with a certificate object" do + it "signs an AuthnRequest with a certificate object" do request_doc = RubySaml::Authrequest.new.create_authentication_xml_doc(settings) request_doc = RubySaml::XML::DocumentSigner.sign_document(request_doc, ruby_saml_key, ruby_saml_cert) @@ -264,7 +264,7 @@ class XmlTest < Minitest::Test assert RubySaml::XML::SignedDocumentValidator.validate_document(request_doc.to_s, ruby_saml_cert_fingerprint, soft: false) end - it "signs an AuthNRequest with a certificate string" do + it "signs an AuthnRequest with a certificate string" do request_doc = RubySaml::Authrequest.new.create_authentication_xml_doc(settings) request_doc = RubySaml::XML::DocumentSigner.sign_document(request_doc, ruby_saml_key, ruby_saml_cert_text) From 1fa217207b0f988528614859fdbfd6194fa89f56 Mon Sep 17 00:00:00 2001 From: johnnyshields <27655+johnnyshields@users.noreply.github.com> Date: Thu, 20 Mar 2025 02:03:56 +0900 Subject: [PATCH 2/7] WIP --- lib/ruby_saml/metadata.rb | 2 +- lib/ruby_saml/sp/builders/authn_request.rb | 19 ++- lib/ruby_saml/sp/builders/logout_request.rb | 19 ++- lib/ruby_saml/sp/builders/logout_response.rb | 12 +- lib/ruby_saml/sp/builders/message_builder.rb | 144 ++++++------------- lib/ruby_saml/utils.rb | 17 --- 6 files changed, 83 insertions(+), 130 deletions(-) diff --git a/lib/ruby_saml/metadata.rb b/lib/ruby_saml/metadata.rb index 58a64ce4..293059fe 100644 --- a/lib/ruby_saml/metadata.rb +++ b/lib/ruby_saml/metadata.rb @@ -19,7 +19,7 @@ def generate(settings, pretty_print = false, valid_until = nil, cache_duration = root_attributes = { 'xmlns:md' => RubySaml::XML::NS_METADATA, 'xmlns:ds' => RubySaml::XML::DSIG, - 'ID' => RubySaml::Utils.uuid, + 'ID' => RubySaml::Utils.generate_uuid, 'entityID' => settings.sp_entity_id } diff --git a/lib/ruby_saml/sp/builders/authn_request.rb b/lib/ruby_saml/sp/builders/authn_request.rb index cd9a18c9..48eecd22 100644 --- a/lib/ruby_saml/sp/builders/authn_request.rb +++ b/lib/ruby_saml/sp/builders/authn_request.rb @@ -4,21 +4,28 @@ module RubySaml module Sp module Builders # SAML AuthnRequest builder (SSO, SP-initiated) - class AuthnRequest < MessageBuilder - alias_method :request_id, :uuid + module AuthnRequest + extend MessageBuilder + extend self - def create(settings, old_params = {}, relay_state: nil) + def create(settings, old_params = {}, uuid: nil, relay_state: nil) super end private - def message_flow - :sso + # Determine the binding type from settings + def binding_type(settings) + settings.idp_sso_service_binding + end + + # Get the service URL from settings based on type + def service_url(settings) + settings.idp_sso_service_url end # Build the XML document - def create_xml(settings) + def create_xml(settings, uuid: nil) time = Time.now.utc.strftime("%Y-%m-%dT%H:%M:%SZ") root_attributes = { diff --git a/lib/ruby_saml/sp/builders/logout_request.rb b/lib/ruby_saml/sp/builders/logout_request.rb index 61717d91..c1510b52 100644 --- a/lib/ruby_saml/sp/builders/logout_request.rb +++ b/lib/ruby_saml/sp/builders/logout_request.rb @@ -4,21 +4,28 @@ module RubySaml module Sp module Builders # SAML LogoutRequest builder (SLO, SP-initiated) - class LogoutRequest < MessageBuilder - alias_method :request_id, :uuid + class LogoutRequest + extend MessageBuilder + extend self - def create(settings, old_params = {}, relay_state: nil) + def create(settings, old_params = {}, uuid: nil, relay_state: nil) super end private - def message_flow - :slo + # Determine the binding type from settings + def binding_type(settings) + settings.idp_slo_service_binding + end + + # Get the service URL from settings based on type + def service_url(settings) + settings.idp_slo_service_url end # Build the XML document - def create_xml(settings) + def create_xml(settings, uuid: nil) time = Time.now.utc.strftime('%Y-%m-%dT%H:%M:%SZ') root_attributes = { diff --git a/lib/ruby_saml/sp/builders/logout_response.rb b/lib/ruby_saml/sp/builders/logout_response.rb index b0d265fb..5320bf26 100644 --- a/lib/ruby_saml/sp/builders/logout_response.rb +++ b/lib/ruby_saml/sp/builders/logout_response.rb @@ -9,12 +9,18 @@ class LogoutResponse < MessageBuilder private - def message_flow - :slo + # Determine the binding type from settings + def binding_type(settings) + settings.idp_slo_service_binding + end + + # Get the service URL from settings based on type + def service_url(settings) + settings.idp_slo_response_service_url || settings.idp_slo_service_url end # Build the XML document - def create_xml(settings, request_id: nil, status_message: nil, status_code: nil) + def create_xml(settings, uuid: nil, status_message: nil, status_code: nil) time = Time.now.utc.strftime('%Y-%m-%dT%H:%M:%SZ') # Default values if not provided diff --git a/lib/ruby_saml/sp/builders/message_builder.rb b/lib/ruby_saml/sp/builders/message_builder.rb index f7282dce..5880b095 100644 --- a/lib/ruby_saml/sp/builders/message_builder.rb +++ b/lib/ruby_saml/sp/builders/message_builder.rb @@ -6,22 +6,17 @@ module Messages class MessageBuilder < Message attr_accessor :uuid - # Generate a signed XML document - # def build_message(settings, root_name, attributes, security_option, &block) - # document = create_xml_document(root_name, attributes, &block) - # sign_document(document, settings, security_option) - # end + # TODO: REFACTOR TO HAVE THESE: + # public methods: + # - create_url + # - create_url_params + # - create_xml # - # # Create URL parameters including signatures if needed - # def build_params(settings, xml_doc, relay_state, param_name, security_option, binding_type) - # binding_redirect = binding_type == Utils::BINDINGS[:redirect] - # create_params(settings, xml_doc, binding_redirect, relay_state, security_option, param_name) - # end - # - # # Build the final URL - # def build_url(settings, params, service_url, param_name) - # create_url(settings, params, service_url, param_name) - # end + # private methods: + # - create_unsigned_xml + # - ... + + # Create the LogoutResponse def create(settings, @@ -38,48 +33,35 @@ def create(settings, @uuid = generate_uuid(settings) relay_state = process_relay_state(params) - - - service_url = service_url(settings, :slo_response) + # LogoutResponse + service_url = service_url(settings, :slo) raise SettingsError.new('Missing IdP SLO service URL') if service_url.nil? || service_url.empty? + # LogoutRequest + # service_url = service_url(settings, :slo) + # raise SettingsError.new("Missing IdP SLO service URL") if service_url.nil? || service_url.empty? + # AuthnRequest + # service_url = service_url(settings, :sso) + # raise SettingsError.new("Missing IdP SSO service URL") if service_url.nil? || service_url.empty? xml_doc = create_logout_response_xml(settings, request_id, logout_message, status_code) binding = binding_type(settings, :slo) - response_params = build_params( - settings, xml_doc, relay_state, "SAMLResponse", :logout_responses_signed, binding + params = build_params( + settings, xml_doc, relay_state, 'SAMLResponse', :logout_responses_signed, binding ) - - @logout_url = build_url(settings, response_params, service_url, "SAMLResponse") + # request_params = build_params( + # settings, xml_doc, relay_state, "SAMLRequest", :logout_requests_signed, binding + # ) + # request_params = build_params( + # settings, xml_doc, relay_state, 'SAMLRequest', :authn_requests_signed, binding + # ) + + build_url(settings, params, service_url, 'SAMLResponse') end - # Create the LogoutRequest - def create(settings, - old_params = {}, - relay_state: nil) - # @uuid = generate_uuid(settings) - # relay_state = process_relay_state(params) - - - - - service_url = service_url(settings, :slo) - raise SettingsError.new("Missing IdP SLO service URL") if service_url.nil? || service_url.empty? - - - - - xml_doc = create_xml(settings) - - binding = binding_type(settings, :slo) - request_params = build_params( - settings, xml_doc, relay_state, "SAMLRequest", :logout_requests_signed, binding - ) - - @logout_url = build_url(settings, request_params, service_url, "SAMLRequest") - end + private def deprecate_positional_args(old_request_id, old_status_message, old_relay_state, old_status_code) return if old_request_id.nil? && old_status_message.nil? && old_relay_state.nil? && old_status_code.nil? @@ -88,51 +70,11 @@ def deprecate_positional_args(old_request_id, old_status_message, old_relay_stat 'Please use keyword arguments instead.' end - # Create the AuthnRequest - def create(settings, - old_params = {}, - relay_state: nil) - # @uuid = generate_uuid(settings) - # relay_state = process_relay_state(params) - - - - - service_url = service_url(settings, :sso) - raise SettingsError.new("Missing IdP SSO service URL") if service_url.nil? || service_url.empty? - - - - - xml_doc = create_authn_request_xml(settings) - - binding = binding_type(settings, :sso) - request_params = build_params( - settings, xml_doc, relay_state, 'SAMLRequest', :authn_requests_signed, binding - ) - - @login_url = build_url(settings, request_params, service_url, "SAMLRequest") - end - - private - - - - # Get the service URL from settings based on type - def service_url(settings, type) - case type - when :sso then settings.idp_sso_service_url - when :slo then settings.idp_slo_service_url - when :slo_response then settings.idp_slo_response_service_url || settings.idp_slo_service_url - else nil - end - end - # Create URL with SAML parameters - def create_url(params, service_url, param_name) + def build_url(params, service_url, param_name) raise ValidationError.new("Service URL cannot be nil") if service_url.nil? || service_url.empty? - params_prefix = /\?/.match?(service_url) ? '&' : '?' + params_prefix = service_url.include?('?') ? '&' : '?' param_value = CGI.escape(params.delete(param_name)) url_params = +"#{params_prefix}#{param_name}=#{param_value}" @@ -162,7 +104,7 @@ def build_signature_params(settings, base64_data, relay_state, binding_redirect, if binding_redirect && settings.security[security_option] && sp_signing_key params['SigAlg'] = settings.get_sp_signature_method - url_string = RubySaml::Utils.build_query( + url_string = build_query( type: @request_type, data: base64_data, relay_state: relay_state, @@ -205,13 +147,21 @@ def create_params(settings, xml_doc, binding_redirect, relay_state, security_opt message_params end - # Determine the binding type from settings - def binding_type(settings, type = :service) - case type - when :sso then settings.idp_sso_service_binding - when :slo then settings.idp_slo_service_binding - else nil - end + # Build the Query String signature that will be used in the HTTP-Redirect binding + # to generate the Signature + # @param params [Hash] Parameters to build the Query String + # @option params [String] :type 'SAMLRequest' or 'SAMLResponse' + # @option params [String] :data Base64 encoded SAMLRequest or SAMLResponse + # @option params [String] :relay_state The RelayState parameter + # @option params [String] :sig_alg The SigAlg parameter + # @return [String] The Query String + def build_query(params) + type, data, relay_state, sig_alg = params.values_at(:type, :data, :relay_state, :sig_alg) + + url_string = +"#{type}=#{CGI.escape(data)}" + url_string << "&RelayState=#{CGI.escape(relay_state)}" if relay_state + url_string << "&SigAlg=#{CGI.escape(sig_alg)}" + url_string end # Generate a UUID diff --git a/lib/ruby_saml/utils.rb b/lib/ruby_saml/utils.rb index 45b2d3bc..fb40a59d 100644 --- a/lib/ruby_saml/utils.rb +++ b/lib/ruby_saml/utils.rb @@ -145,23 +145,6 @@ def build_private_key_object(pem) raise error end - # Build the Query String signature that will be used in the HTTP-Redirect binding - # to generate the Signature - # @param params [Hash] Parameters to build the Query String - # @option params [String] :type 'SAMLRequest' or 'SAMLResponse' - # @option params [String] :data Base64 encoded SAMLRequest or SAMLResponse - # @option params [String] :relay_state The RelayState parameter - # @option params [String] :sig_alg The SigAlg parameter - # @return [String] The Query String - # - def build_query(params) - type, data, relay_state, sig_alg = params.values_at(:type, :data, :relay_state, :sig_alg) - - url_string = +"#{type}=#{CGI.escape(data)}" - url_string << "&RelayState=#{CGI.escape(relay_state)}" if relay_state - url_string << "&SigAlg=#{CGI.escape(sig_alg)}" - end - # Reconstruct a canonical query string from raw URI-encoded parts, to be used in verifying a signature # # @param params [Hash] Parameters to build the Query String From e51b8ad7567e92337c2507b0c97e2ab7312418c9 Mon Sep 17 00:00:00 2001 From: johnnyshields <27655+johnnyshields@users.noreply.github.com> Date: Thu, 20 Mar 2025 04:09:23 +0900 Subject: [PATCH 3/7] More refactor --- lib/ruby_saml/sp/builders/authn_request.rb | 102 +++--- lib/ruby_saml/sp/builders/logout_request.rb | 72 ++--- lib/ruby_saml/sp/builders/logout_response.rb | 75 +++-- lib/ruby_saml/sp/builders/message_builder.rb | 297 +++++++++--------- lib/ruby_saml/{ => sp}/old/authrequest.rb | 0 lib/ruby_saml/{ => sp}/old/logoutrequest.rb | 0 .../{ => sp}/old/slo_logoutresponse.rb | 0 lib/ruby_saml/utils.rb | 4 + 8 files changed, 288 insertions(+), 262 deletions(-) rename lib/ruby_saml/{ => sp}/old/authrequest.rb (100%) rename lib/ruby_saml/{ => sp}/old/logoutrequest.rb (100%) rename lib/ruby_saml/{ => sp}/old/slo_logoutresponse.rb (100%) diff --git a/lib/ruby_saml/sp/builders/authn_request.rb b/lib/ruby_saml/sp/builders/authn_request.rb index 48eecd22..7f2c0efc 100644 --- a/lib/ruby_saml/sp/builders/authn_request.rb +++ b/lib/ruby_saml/sp/builders/authn_request.rb @@ -4,78 +4,78 @@ module RubySaml module Sp module Builders # SAML AuthnRequest builder (SSO, SP-initiated) - module AuthnRequest - extend MessageBuilder - extend self - - def create(settings, old_params = {}, uuid: nil, relay_state: nil) - super - end + class AuthnRequest < MessageBuilder private + def message_type + 'SAMLRequest' + end + # Determine the binding type from settings - def binding_type(settings) + def binding_type settings.idp_sso_service_binding end # Get the service URL from settings based on type - def service_url(settings) - settings.idp_sso_service_url + def service_url + url = settings.idp_sso_service_url + raise SettingError.new "Invalid settings, idp_sso_service_url is not set!" if url.nil? || url.empty? + url end - # Build the XML document - def create_xml(settings, uuid: nil) - time = Time.now.utc.strftime("%Y-%m-%dT%H:%M:%SZ") + def sign? + settings.security[:authn_requests_signed] + end - root_attributes = { - 'xmlns:samlp' => RubySaml::XML::NS_PROTOCOL, - 'xmlns:saml' => RubySaml::XML::NS_ASSERTION, - 'ID' => uuid, - 'IssueInstant' => time, - 'Version' => '2.0', - 'Destination' => settings.idp_sso_service_url, - 'IsPassive' => settings.passive, - 'ProtocolBinding' => settings.protocol_binding, - 'AttributeConsumingServiceIndex' => settings.attributes_index, - 'ForceAuthn' => settings.force_authn, - 'AssertionConsumerServiceURL' => settings.assertion_consumer_service_url - } + # TODO: Re-add comments + def build_xml_document + Nokogiri::XML::Builder.new do |xml| + xml['samlp'].AuthnRequest(compact_blank(xml_root_attributes)) do - build_message(settings, 'AuthnRequest', root_attributes, :authn_requests_signed) do |xml| - # Add Issuer - xml['saml'].Issuer(settings.sp_entity_id) if settings.sp_entity_id + # Add Issuer element if sp_entity_id is present + xml['saml'].Issuer(settings.sp_entity_id) if settings.sp_entity_id - # Add Subject if requested - if settings.name_identifier_value_requested - xml['saml'].Subject do - nameid_attrs = {} - nameid_attrs['Format'] = settings.name_identifier_format if settings.name_identifier_format - xml['saml'].NameID(settings.name_identifier_value_requested, nameid_attrs) - xml['saml'].SubjectConfirmation(Method: 'urn:oasis:names:tc:SAML:2.0:cm:bearer') + if settings.name_identifier_value_requested + xml['saml'].Subject do + xml['saml'].NameID(settings.name_identifier_value_requested, xml_nameid_attributes) + xml['saml'].SubjectConfirmation(Method: 'urn:oasis:names:tc:SAML:2.0:cm:bearer') + end end - end - # Add NameIDPolicy if format is specified - if settings.name_identifier_format - xml['samlp'].NameIDPolicy(AllowCreate: 'true', Format: settings.name_identifier_format) - end + if settings.name_identifier_format + xml['samlp'].NameIDPolicy(AllowCreate: 'true', Format: settings.name_identifier_format) + end - # Add RequestedAuthnContext if needed - if settings.authn_context || settings.authn_context_decl_ref - comparison = settings.authn_context_comparison || 'exact' + if settings.authn_context || settings.authn_context_decl_ref + comparison = settings.authn_context_comparison || 'exact' - xml['samlp'].RequestedAuthnContext(Comparison: comparison) do - Array(settings.authn_context).each do |context_class| - xml['saml'].AuthnContextClassRef(context_class) - end + xml['samlp'].RequestedAuthnContext(Comparison: comparison) do + Array(settings.authn_context).each do |authn_context_class_ref| + xml['saml'].AuthnContextClassRef(authn_context_class_ref) + end - Array(settings.authn_context_decl_ref).each do |decl_ref| - xml['saml'].AuthnContextDeclRef(decl_ref) + Array(settings.authn_context_decl_ref).each do |authn_context_decl_ref| + xml['saml'].AuthnContextDeclRef(authn_context_decl_ref) + end end end end - end + end.doc + end + + def xml_root_attributes + hash = super + hash['IsPassive'] = settings.passive, + hash['ProtocolBinding'] = settings.protocol_binding, + hash['AttributeConsumingServiceIndex'] = settings.attributes_index, + hash['ForceAuthn'] = settings.force_authn, + hash['AssertionConsumerServiceURL'] = settings.assertion_consumer_service_url + compact_blank!(hash) + end + + def xml_nameid_attributes + compact_blank!('Format' => settings.name_identifier_format) end end end diff --git a/lib/ruby_saml/sp/builders/logout_request.rb b/lib/ruby_saml/sp/builders/logout_request.rb index c1510b52..835b693b 100644 --- a/lib/ruby_saml/sp/builders/logout_request.rb +++ b/lib/ruby_saml/sp/builders/logout_request.rb @@ -4,59 +4,53 @@ module RubySaml module Sp module Builders # SAML LogoutRequest builder (SLO, SP-initiated) - class LogoutRequest - extend MessageBuilder - extend self - - def create(settings, old_params = {}, uuid: nil, relay_state: nil) - super - end + class LogoutRequest < MessageBuilder private + def message_type + 'SAMLRequest' + end + # Determine the binding type from settings - def binding_type(settings) + def binding_type settings.idp_slo_service_binding end - # Get the service URL from settings based on type - def service_url(settings) - settings.idp_slo_service_url + # Get the service URL from settings based on type with validation + def service_url + url = settings.idp_slo_service_url + raise SettingError.new "Invalid settings, idp_slo_service_url is not set!" if url.nil? || url.empty? + url end - # Build the XML document - def create_xml(settings, uuid: nil) - time = Time.now.utc.strftime('%Y-%m-%dT%H:%M:%SZ') + def sign? + settings.security[:logout_requests_signed] + end - root_attributes = { - 'xmlns:samlp' => RubySaml::XML::NS_PROTOCOL, - 'xmlns:saml' => RubySaml::XML::NS_ASSERTION, - 'ID' => uuid, - 'IssueInstant' => time, - 'Version' => '2.0', - 'Destination' => settings.idp_slo_service_url - } + def build_xml_document + Nokogiri::XML::Builder.new do |xml| + xml['samlp'].LogoutRequest(compact_blank(xml_root_attributes)) do + xml['saml'].Issuer(settings.sp_entity_id) if settings.sp_entity_id - build_message(settings, 'LogoutRequest', root_attributes, :logout_requests_signed) do |xml| - # Add Issuer - xml['saml'].Issuer(settings.sp_entity_id) if settings.sp_entity_id + if settings.name_identifier_value + xml['saml'].NameID(settings.name_identifier_value, xml_nameid_attributes) + else + xml['saml'].NameID(RubySaml::Utils.generate_uuid, + 'Format' => 'urn:oasis:names:tc:SAML:2.0:nameid-format:transient') + end - # Add NameID - if settings.name_identifier_value - nameid_attrs = { - 'NameQualifier' => settings.idp_name_qualifier, - 'SPNameQualifier' => settings.sp_name_qualifier, - 'Format' => settings.name_identifier_format - } - xml['saml'].NameID(settings.name_identifier_value, clean_attributes(nameid_attrs)) - else - xml['saml'].NameID(RubySaml::Utils.uuid, - 'Format' => 'urn:oasis:names:tc:SAML:2.0:nameid-format:transient') + xml['samlp'].SessionIndex(settings.sessionindex) if settings.sessionindex end + end.doc + end - # Add SessionIndex if provided - xml['samlp'].SessionIndex(settings.sessionindex) if settings.sessionindex - end + def xml_nameid_attributes + compact_blank!( + 'NameQualifier' => settings.idp_name_qualifier, + 'SPNameQualifier' => settings.sp_name_qualifier, + 'Format' => settings.name_identifier_format + ) end end end diff --git a/lib/ruby_saml/sp/builders/logout_response.rb b/lib/ruby_saml/sp/builders/logout_response.rb index 5320bf26..887cb95e 100644 --- a/lib/ruby_saml/sp/builders/logout_response.rb +++ b/lib/ruby_saml/sp/builders/logout_response.rb @@ -5,48 +5,59 @@ module Sp module Builders # SAML LogoutResponse builder (SLO, IdP-initiated) class LogoutResponse < MessageBuilder - alias_method :response_id, :uuid + DEFAULT_STATUS_CODE = 'urn:oasis:names:tc:SAML:2.0:status:Success' + DEFAULT_STATUS_MESSAGE = 'Successfully Signed Out' + + def initialize(settings, in_response_to:, uuid: nil, relay_state: nil, status_code: nil, status_message: nil) + super(settings, uuid: uuid, relay_state: relay_state) + @in_response_to = in_response_to + @status_code = status_code || DEFAULT_STATUS_CODE + @status_message = status_message || DEFAULT_STATUS_MESSAGE + end private + attr_reader :in_response_to, + :status_code, + :status_message + + def message_type + 'SAMLResponse' + end + # Determine the binding type from settings - def binding_type(settings) + def binding_type settings.idp_slo_service_binding end - # Get the service URL from settings based on type - def service_url(settings) - settings.idp_slo_response_service_url || settings.idp_slo_service_url + # Get the service URL from settings with validation + def service_url + url = settings.idp_slo_response_service_url || settings.idp_slo_service_url + raise SettingError.new "Invalid settings, IdP SLO service URL is not set!" if url.nil? || url.empty? + url end - # Build the XML document - def create_xml(settings, uuid: nil, status_message: nil, status_code: nil) - time = Time.now.utc.strftime('%Y-%m-%dT%H:%M:%SZ') - - # Default values if not provided - status_code ||= 'urn:oasis:names:tc:SAML:2.0:status:Success' - status_message ||= 'Successfully Signed Out' - - root_attributes = { - 'xmlns:samlp' => RubySaml::XML::NS_PROTOCOL, - 'xmlns:saml' => RubySaml::XML::NS_ASSERTION, - 'ID' => uuid, - 'IssueInstant' => time, - 'Version' => '2.0', - 'InResponseTo' => request_id, - 'Destination' => service_url(settings, :slo_response) - }.compact - - build_message(settings, 'LogoutResponse', root_attributes, :logout_responses_signed) do |xml| - # Add Issuer - xml['saml'].Issuer(settings.sp_entity_id) if settings.sp_entity_id - - # Add Status - xml['samlp'].Status do - xml['samlp'].StatusCode(Value: status_code) - xml['samlp'].StatusMessage(status_message) + def sign? + settings.security[:logout_responses_signed] + end + + def build_xml_document + Nokogiri::XML::Builder.new do |xml| + xml['samlp'].LogoutResponse(xml_root_attributes) do + xml['saml'].Issuer(settings.sp_entity_id) if settings.sp_entity_id + + xml['samlp'].Status do + xml['samlp'].StatusCode(Value: status_code) + xml['samlp'].StatusMessage(status_message) + end end - end + end.doc + end + + def xml_root_attributes + hash = super + hash['InResponseTo'] = in_response_to + compact_blank!(hash) end end end diff --git a/lib/ruby_saml/sp/builders/message_builder.rb b/lib/ruby_saml/sp/builders/message_builder.rb index 5880b095..69eef702 100644 --- a/lib/ruby_saml/sp/builders/message_builder.rb +++ b/lib/ruby_saml/sp/builders/message_builder.rb @@ -1,172 +1,189 @@ # frozen_string_literal: true module RubySaml - module Messages - # Base builder for SP-initiated SAML messages - class MessageBuilder < Message - attr_accessor :uuid - - # TODO: REFACTOR TO HAVE THESE: - # public methods: - # - create_url - # - create_url_params - # - create_xml - # - # private methods: - # - create_unsigned_xml - # - ... - - - - # Create the LogoutResponse - def create(settings, - old_request_id = nil, - old_status_message = nil, - old_relay_state = nil, - old_status_code = nil, - relay_state: nil, - request_id: nil, - status_code: nil, - status_message: nil) - deprecate_positional_args(old_request_id, old_status_message, old_relay_state, old_status_code) - - @uuid = generate_uuid(settings) - relay_state = process_relay_state(params) - - # LogoutResponse - service_url = service_url(settings, :slo) - raise SettingsError.new('Missing IdP SLO service URL') if service_url.nil? || service_url.empty? - - # LogoutRequest - # service_url = service_url(settings, :slo) - # raise SettingsError.new("Missing IdP SLO service URL") if service_url.nil? || service_url.empty? - - # AuthnRequest - # service_url = service_url(settings, :sso) - # raise SettingsError.new("Missing IdP SSO service URL") if service_url.nil? || service_url.empty? - - xml_doc = create_logout_response_xml(settings, request_id, logout_message, status_code) - - binding = binding_type(settings, :slo) - params = build_params( - settings, xml_doc, relay_state, 'SAMLResponse', :logout_responses_signed, binding - ) - # request_params = build_params( - # settings, xml_doc, relay_state, "SAMLRequest", :logout_requests_signed, binding - # ) - # request_params = build_params( - # settings, xml_doc, relay_state, 'SAMLRequest', :authn_requests_signed, binding - # ) - - build_url(settings, params, service_url, 'SAMLResponse') - end + module Sp + module Builders + # Base builder for SAML messages + class MessageBuilder + def initialize(settings, uuid: nil, params: nil, relay_state: nil) + @settings = settings + @uuid = uuid || generate_uuid + @relay_state = relay_state + end - private + def url + binding_redirect? ? redirect_uri : post_uri + end - def deprecate_positional_args(old_request_id, old_status_message, old_relay_state, old_status_code) - return if old_request_id.nil? && old_status_message.nil? && old_relay_state.nil? && old_status_code.nil? + def params + binding_redirect? ? nil : post_params + end - warn 'DEPRECATION WARNING: Positional arguments for RubySaml::Messages::MessageBuilder#create are deprecated. ' \ - 'Please use keyword arguments instead.' - end + def redirect_url - # Create URL with SAML parameters - def build_url(params, service_url, param_name) - raise ValidationError.new("Service URL cannot be nil") if service_url.nil? || service_url.empty? + end + memoize_method :redirect_url + + alias_method :post_url, :service_url - params_prefix = service_url.include?('?') ? '&' : '?' - param_value = CGI.escape(params.delete(param_name)) - url_params = +"#{params_prefix}#{param_name}=#{param_value}" + def post_params - params.each_pair do |key, value| - url_params << "&#{key}=#{CGI.escape(value.to_s)}" end + memoize_method :post_params + - service_url + url_params - end - # Process relay state from params - def process_relay_state(params) - relay_state = params[:RelayState] || params['RelayState'] + # TODO: + # if saml_settings.idp_sso_service_binding.end_with?('HTTP-POST') + # render json: { http_post_uri: saml_settings.idp_sso_service_url, + # http_post_params: auth.create_params(saml_settings, auth_params) } + # else + # render json: { http_redirect_uri: auth.create(saml_settings, auth_params) } + # end - if relay_state.nil? - params.delete(:RelayState) - params.delete('RelayState') + + + # TODO: Add this method + def extract_relay_state(relay_state, params) + # relay_state = params[:RelayState] || params['RelayState'] + # if relay_state.nil? + # params.delete(:RelayState) + # params.delete('RelayState') + # end end - relay_state - end + def url + params_prefix = service_url.include?('?') ? '&' : '?' + param_value = CGI.escape(url_params.delete(message_type)) + query = +"#{params_prefix}#{message_type}=#{param_value}" + params.each_pair do |key, value| + query << "&#{key}=#{CGI.escape(value.to_s)}" + end + service_url + query + end + memoize_method :url + alias_method :create_url, :url - # Build signature parameters - def build_signature_params(settings, base64_data, relay_state, binding_redirect, security_option) - params = {} - sp_signing_key = settings.get_sp_signing_key - - if binding_redirect && settings.security[security_option] && sp_signing_key - params['SigAlg'] = settings.get_sp_signature_method - url_string = build_query( - type: @request_type, - data: base64_data, - relay_state: relay_state, - sig_alg: params['SigAlg'] + def url_params + # raw params + end + + def url_query + message = xml_doc.to_xml(save_with: Nokogiri::XML::Node::SaveOptions::AS_XML) + base64_message = RubySaml::XML::Decoder.encode_message(message, compress: binding_redirect?) + message_params = { message_type => base64_message } + message_params[:RelayState] = relay_state if relay_state + message_params.merge( + build_signature_params( + base64_message, + relay_state, + binding_redirect, + message_type + ) ) - sign_algorithm = RubySaml::XML.hash_algorithm(settings.get_sp_signature_method) - signature = sp_signing_key.sign(sign_algorithm.new, url_string) - params['Signature'] = Base64.strict_encode64(signature) + end + memoize_method :url_query + + def build_signature_params + if binding_redirect && sign? && signing_key + url_string = +"#{message_type}=#{CGI.escape(data)}" + url_string << "&RelayState=#{CGI.escape(relay_state)}" if relay_state + url_string << "&SigAlg=#{CGI.escape(signature_method)}" + url_string + signature = signing_key.sign(signature_hash_algorithm.new, url_string) + params['Signature'] = Base64.strict_encode64(signature) + end + params end - params - end + def xml + noko = build_xml_document + sign_document!(noko) + noko.to_xml(save_with: Nokogiri::XML::Node::SaveOptions::AS_XML) + end + memoize_method :xml + alias_method :create_xml, :xml + + private + + attr_reader :settings, + :uuid, + :relay_state + + # def create_unsigned_xml + # create_xml_document(settings) + # end + + def xml_root_attributes + compact_blank!( + 'xmlns:samlp' => RubySaml::XML::NS_PROTOCOL, + 'xmlns:saml' => RubySaml::XML::NS_ASSERTION, + 'ID' => uuid, + 'IssueInstant' => RubySaml::Utils.utc_timestamp, + 'Version' => '2.0', + 'Destination' => service_url + ) + end - # Create parameters for SAML request/response - def create_params(settings, xml_doc, binding_redirect, relay_state, security_option, param_name) - request_doc = xml_doc - message = request_doc.to_xml(save_with: Nokogiri::XML::Node::SaveOptions::AS_XML) + def sign_document!(noko) + return unless sign? - Logging.debug "Created #{param_name}: #{message}" + cert, private_key = settings.get_sp_signing_pair + return noko unless binding_post? && sign? && private_key && cert - @request_type = param_name - base64_message = RubySaml::XML::Decoder.encode_message(message, compress: binding_redirect) - message_params = { param_name => base64_message } + RubySaml::XML::DocumentSigner.sign_document!( + noko, + private_key, + cert, + signature_method, + digest_method + ) + end - signature_params = build_signature_params( - settings, - base64_message, - relay_state, - binding_redirect, - security_option - ) + def compact_blank!(hash) + hash.reject! { |_, v| v.nil? || (v.respond_to?(:empty?) && v.empty?) } + hash + end - params = {} - params.merge!(signature_params) + # def xml_document + # raise NoMethodError.new('Subclass must implement binding_type') + # end + # TODO: add these + # def binding_type(settings) + # raise NoMethodError.new('Subclass must implement binding_type') + # end + # + # def service_url(settings) + # raise NoMethodError.new('Subclass must implement service_url') + # end + + def generate_uuid + RubySaml::Utils.generate_uuid(settings.sp_uuid_prefix) + end - params.each_pair do |key, value| - message_params[key] = value.to_s + def binding_redirect? + binding_type == Utils::BINDINGS[:redirect] end - message_params - end + def binding_post? + !binding_redirect? + end - # Build the Query String signature that will be used in the HTTP-Redirect binding - # to generate the Signature - # @param params [Hash] Parameters to build the Query String - # @option params [String] :type 'SAMLRequest' or 'SAMLResponse' - # @option params [String] :data Base64 encoded SAMLRequest or SAMLResponse - # @option params [String] :relay_state The RelayState parameter - # @option params [String] :sig_alg The SigAlg parameter - # @return [String] The Query String - def build_query(params) - type, data, relay_state, sig_alg = params.values_at(:type, :data, :relay_state, :sig_alg) - - url_string = +"#{type}=#{CGI.escape(data)}" - url_string << "&RelayState=#{CGI.escape(relay_state)}" if relay_state - url_string << "&SigAlg=#{CGI.escape(sig_alg)}" - url_string - end + def signing_key + @signing_key ||= settings.get_sp_signing_key + end + + def signature_method + @signature_method ||= settings.sp_signature_method + end + + def signature_hash_algorithm + @signature_algorithm ||= RubySaml::XML.hash_algorithm(signature_method) + end - # Generate a UUID - def generate_uuid(settings) - RubySaml::Utils.generate_uuid(settings.sp_uuid_prefix) + def digest_method + @digest_method ||= settings.get_sp_digest_method + end end end end diff --git a/lib/ruby_saml/old/authrequest.rb b/lib/ruby_saml/sp/old/authrequest.rb similarity index 100% rename from lib/ruby_saml/old/authrequest.rb rename to lib/ruby_saml/sp/old/authrequest.rb diff --git a/lib/ruby_saml/old/logoutrequest.rb b/lib/ruby_saml/sp/old/logoutrequest.rb similarity index 100% rename from lib/ruby_saml/old/logoutrequest.rb rename to lib/ruby_saml/sp/old/logoutrequest.rb diff --git a/lib/ruby_saml/old/slo_logoutresponse.rb b/lib/ruby_saml/sp/old/slo_logoutresponse.rb similarity index 100% rename from lib/ruby_saml/old/slo_logoutresponse.rb rename to lib/ruby_saml/sp/old/slo_logoutresponse.rb diff --git a/lib/ruby_saml/utils.rb b/lib/ruby_saml/utils.rb index fb40a59d..2ebeba61 100644 --- a/lib/ruby_saml/utils.rb +++ b/lib/ruby_saml/utils.rb @@ -288,5 +288,9 @@ def private_key_classes(pem) end Array(priority) | [OpenSSL::PKey::RSA, OpenSSL::PKey::DSA, OpenSSL::PKey::EC] end + + def utc_timestamp(time = Time.now) + time.utc.strftime('%Y-%m-%dT%H:%M:%SZ') + end end end From 788583bdf069b133c49e95759c9e4c1d2dbbc9f4 Mon Sep 17 00:00:00 2001 From: johnnyshields <27655+johnnyshields@users.noreply.github.com> Date: Thu, 20 Mar 2025 13:56:52 +0900 Subject: [PATCH 4/7] More refactor --- lib/ruby_saml.rb | 1 + lib/ruby_saml/sp/builders/authn_request.rb | 8 +- lib/ruby_saml/sp/builders/logout_response.rb | 4 +- lib/ruby_saml/sp/builders/message_builder.rb | 175 +++++++------------ 4 files changed, 68 insertions(+), 120 deletions(-) diff --git a/lib/ruby_saml.rb b/lib/ruby_saml.rb index 97fceee0..3ca95998 100644 --- a/lib/ruby_saml.rb +++ b/lib/ruby_saml.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true require 'cgi' +require 'uri' require 'zlib' require 'base64' require 'time' diff --git a/lib/ruby_saml/sp/builders/authn_request.rb b/lib/ruby_saml/sp/builders/authn_request.rb index 7f2c0efc..1984bad1 100644 --- a/lib/ruby_saml/sp/builders/authn_request.rb +++ b/lib/ruby_saml/sp/builders/authn_request.rb @@ -66,10 +66,10 @@ def build_xml_document def xml_root_attributes hash = super - hash['IsPassive'] = settings.passive, - hash['ProtocolBinding'] = settings.protocol_binding, - hash['AttributeConsumingServiceIndex'] = settings.attributes_index, - hash['ForceAuthn'] = settings.force_authn, + hash['IsPassive'] = settings.passive + hash['ProtocolBinding'] = settings.protocol_binding + hash['AttributeConsumingServiceIndex'] = settings.attributes_index + hash['ForceAuthn'] = settings.force_authn hash['AssertionConsumerServiceURL'] = settings.assertion_consumer_service_url compact_blank!(hash) end diff --git a/lib/ruby_saml/sp/builders/logout_response.rb b/lib/ruby_saml/sp/builders/logout_response.rb index 887cb95e..8413bead 100644 --- a/lib/ruby_saml/sp/builders/logout_response.rb +++ b/lib/ruby_saml/sp/builders/logout_response.rb @@ -8,8 +8,8 @@ class LogoutResponse < MessageBuilder DEFAULT_STATUS_CODE = 'urn:oasis:names:tc:SAML:2.0:status:Success' DEFAULT_STATUS_MESSAGE = 'Successfully Signed Out' - def initialize(settings, in_response_to:, uuid: nil, relay_state: nil, status_code: nil, status_message: nil) - super(settings, uuid: uuid, relay_state: relay_state) + def initialize(settings, in_response_to:, id: nil, relay_state: nil, params: nil, status_code: nil, status_message: nil) + super(settings, id: id, relay_state: relay_state, params: nil) @in_response_to = in_response_to @status_code = status_code || DEFAULT_STATUS_CODE @status_message = status_message || DEFAULT_STATUS_MESSAGE diff --git a/lib/ruby_saml/sp/builders/message_builder.rb b/lib/ruby_saml/sp/builders/message_builder.rb index 69eef702..582197da 100644 --- a/lib/ruby_saml/sp/builders/message_builder.rb +++ b/lib/ruby_saml/sp/builders/message_builder.rb @@ -3,134 +3,74 @@ module RubySaml module Sp module Builders - # Base builder for SAML messages class MessageBuilder - def initialize(settings, uuid: nil, params: nil, relay_state: nil) + def initialize(settings, id: nil, relay_state: nil, params: nil) @settings = settings - @uuid = uuid || generate_uuid + @id = id || generate_uuid @relay_state = relay_state + @params = normalize_params(params) end def url - binding_redirect? ? redirect_uri : post_uri + binding_redirect? ? redirect_url : post_url end - def params - binding_redirect? ? nil : post_params + def body + post_body unless binding_redirect? end def redirect_url - + query_prefix = service_url.include?('?') ? '&' : '?' + "#{service_url}#{query_prefix}#{URI.encode_www_form(build_payload(true))}" end memoize_method :redirect_url alias_method :post_url, :service_url + memoize_method :post_url - def post_params - + def post_body + build_payload(false) end memoize_method :post_params + private + attr_reader :settings, + :id, + :relay_state - # TODO: - # if saml_settings.idp_sso_service_binding.end_with?('HTTP-POST') - # render json: { http_post_uri: saml_settings.idp_sso_service_url, - # http_post_params: auth.create_params(saml_settings, auth_params) } - # else - # render json: { http_redirect_uri: auth.create(saml_settings, auth_params) } - # end - - - - # TODO: Add this method - def extract_relay_state(relay_state, params) - # relay_state = params[:RelayState] || params['RelayState'] - # if relay_state.nil? - # params.delete(:RelayState) - # params.delete('RelayState') - # end - end + def build_payload(redirect) + noko = build_xml_document + sign_xml_document!(noko) unless redirect + message_data = noko.to_xml(save_with: Nokogiri::XML::Node::SaveOptions::AS_XML) + message_data = RubySaml::XML::Decoder.encode_message(message_data, compress: redirect) - def url - params_prefix = service_url.include?('?') ? '&' : '?' - param_value = CGI.escape(url_params.delete(message_type)) - query = +"#{params_prefix}#{message_type}=#{param_value}" - params.each_pair do |key, value| - query << "&#{key}=#{CGI.escape(value.to_s)}" - end - service_url + query - end - memoize_method :url - alias_method :create_url, :url - - def url_params - # raw params - end - - def url_query - message = xml_doc.to_xml(save_with: Nokogiri::XML::Node::SaveOptions::AS_XML) - base64_message = RubySaml::XML::Decoder.encode_message(message, compress: binding_redirect?) - message_params = { message_type => base64_message } - message_params[:RelayState] = relay_state if relay_state - message_params.merge( - build_signature_params( - base64_message, - relay_state, - binding_redirect, - message_type - ) - ) - end - memoize_method :url_query + payload = { message_type => message_data } + payload['RelayState'] = relay_state if relay_state - def build_signature_params - if binding_redirect && sign? && signing_key - url_string = +"#{message_type}=#{CGI.escape(data)}" - url_string << "&RelayState=#{CGI.escape(relay_state)}" if relay_state - url_string << "&SigAlg=#{CGI.escape(signature_method)}" - url_string - signature = signing_key.sign(signature_hash_algorithm.new, url_string) + if redirect && sign? && signing_key + params['SigAlg'] = signature_method + signed_params = url_encode(params.slice(message_type, 'RelayState', 'SigAlg')) + signature = signing_key.sign(hash_algorithm.new, signed_params) params['Signature'] = Base64.strict_encode64(signature) end - params - end - def xml - noko = build_xml_document - sign_document!(noko) - noko.to_xml(save_with: Nokogiri::XML::Node::SaveOptions::AS_XML) + payload.reverse_merge!(params) + payload end - memoize_method :xml - alias_method :create_xml, :xml - - private - - attr_reader :settings, - :uuid, - :relay_state - - # def create_unsigned_xml - # create_xml_document(settings) - # end def xml_root_attributes compact_blank!( 'xmlns:samlp' => RubySaml::XML::NS_PROTOCOL, 'xmlns:saml' => RubySaml::XML::NS_ASSERTION, - 'ID' => uuid, - 'IssueInstant' => RubySaml::Utils.utc_timestamp, + 'ID' => id, + 'IssueInstant' => utc_timestamp, 'Version' => '2.0', 'Destination' => service_url ) end - def sign_document!(noko) - return unless sign? - - cert, private_key = settings.get_sp_signing_pair - return noko unless binding_post? && sign? && private_key && cert - + def sign_xml_document!(noko) RubySaml::XML::DocumentSigner.sign_document!( noko, private_key, @@ -140,27 +80,6 @@ def sign_document!(noko) ) end - def compact_blank!(hash) - hash.reject! { |_, v| v.nil? || (v.respond_to?(:empty?) && v.empty?) } - hash - end - - # def xml_document - # raise NoMethodError.new('Subclass must implement binding_type') - # end - # TODO: add these - # def binding_type(settings) - # raise NoMethodError.new('Subclass must implement binding_type') - # end - # - # def service_url(settings) - # raise NoMethodError.new('Subclass must implement service_url') - # end - - def generate_uuid - RubySaml::Utils.generate_uuid(settings.sp_uuid_prefix) - end - def binding_redirect? binding_type == Utils::BINDINGS[:redirect] end @@ -177,13 +96,41 @@ def signature_method @signature_method ||= settings.sp_signature_method end - def signature_hash_algorithm - @signature_algorithm ||= RubySaml::XML.hash_algorithm(signature_method) + def hash_algorithm + @hash_algorithm ||= RubySaml::XML.hash_algorithm(signature_method) end def digest_method @digest_method ||= settings.get_sp_digest_method end + + # Intentionally memoized + def utc_timestamp + @utc_timestamp ||= RubySaml::Utils.utc_timestamp + end + + def generate_uuid + RubySaml::Utils.generate_uuid(settings.sp_uuid_prefix) + end + + def normalize_params(params) + (params || {}).to_h do |key, value| + next if value.nil? || value.empty? + + [key.to_s, value.to_s] + end + end + + def compact_blank!(hash) + hash.reject! { |_, v| v.nil? || (v.respond_to?(:empty?) && v.empty?) } + hash + end + + %i[message_type binding_type service_url sign? build_xml_document].each do |method_name| + define_method(method_name) do + raise NoMethodError.new("Subclass must implement #{method_name}") + end + end end end end From 506ffb74afae4d8cc7fe00d294dfbc95ad267950 Mon Sep 17 00:00:00 2001 From: johnnyshields <27655+johnnyshields@users.noreply.github.com> Date: Thu, 20 Mar 2025 14:09:14 +0900 Subject: [PATCH 5/7] Cleanups --- lib/ruby_saml/sp/builders/authn_request.rb | 20 +++++- lib/ruby_saml/sp/builders/logout_request.rb | 17 ++++- lib/ruby_saml/sp/builders/logout_response.rb | 25 ++++++- lib/ruby_saml/sp/builders/message_builder.rb | 68 ++++++++++++++++++-- 4 files changed, 120 insertions(+), 10 deletions(-) diff --git a/lib/ruby_saml/sp/builders/authn_request.rb b/lib/ruby_saml/sp/builders/authn_request.rb index 1984bad1..19df298d 100644 --- a/lib/ruby_saml/sp/builders/authn_request.rb +++ b/lib/ruby_saml/sp/builders/authn_request.rb @@ -3,32 +3,44 @@ module RubySaml module Sp module Builders - # SAML AuthnRequest builder (SSO, SP-initiated) + # SAML2.0 Authentication Request (SSO SP-initiated, Builder) + # + # Creates a SAML AuthnRequest for Service Provider initiated Authentication. + # The XML message is created and embedded into the HTTP-GET or HTTP-POST request + # according to the SAML Binding used. class AuthnRequest < MessageBuilder private + # Returns the message type for the request + # @return [String] The message type def message_type 'SAMLRequest' end # Determine the binding type from settings + # @return [String] The binding type def binding_type settings.idp_sso_service_binding end # Get the service URL from settings based on type + # @return [String] The IdP SSO URL + # @raise [SettingError] if the URL is not set def service_url url = settings.idp_sso_service_url raise SettingError.new "Invalid settings, idp_sso_service_url is not set!" if url.nil? || url.empty? url end + # Determines if the message should be signed + # @return [Boolean] True if the message should be signed def sign? settings.security[:authn_requests_signed] end - # TODO: Re-add comments + # Build the authentication request XML document + # @return [Nokogiri::XML::Document] A XML document containing the request def build_xml_document Nokogiri::XML::Builder.new do |xml| xml['samlp'].AuthnRequest(compact_blank(xml_root_attributes)) do @@ -64,6 +76,8 @@ def build_xml_document end.doc end + # Returns the attributes for the SAML root element + # @return [Hash] A hash of attributes for the SAML root element def xml_root_attributes hash = super hash['IsPassive'] = settings.passive @@ -74,6 +88,8 @@ def xml_root_attributes compact_blank!(hash) end + # Returns the attributes for the NameID element + # @return [Hash] A hash of attributes for the NameID element def xml_nameid_attributes compact_blank!('Format' => settings.name_identifier_format) end diff --git a/lib/ruby_saml/sp/builders/logout_request.rb b/lib/ruby_saml/sp/builders/logout_request.rb index 835b693b..8579f1e8 100644 --- a/lib/ruby_saml/sp/builders/logout_request.rb +++ b/lib/ruby_saml/sp/builders/logout_request.rb @@ -3,31 +3,44 @@ module RubySaml module Sp module Builders - # SAML LogoutRequest builder (SLO, SP-initiated) + # SAML2.0 Logout Request (SLO SP-initiated, Builder) + # + # Creates a SAML LogoutRequest for Service Provider initiated Single Logout. + # The XML message is created and embedded into the HTTP-GET or HTTP-POST request + # according to the SAML Binding used. class LogoutRequest < MessageBuilder private + # Returns the message type for the request + # @return [String] The message type def message_type 'SAMLRequest' end # Determine the binding type from settings + # @return [String] The binding type def binding_type settings.idp_slo_service_binding end # Get the service URL from settings based on type with validation + # @return [String] The IdP SLO URL + # @raise [SettingError] if the URL is not set def service_url url = settings.idp_slo_service_url raise SettingError.new "Invalid settings, idp_slo_service_url is not set!" if url.nil? || url.empty? url end + # Determines if the message should be signed + # @return [Boolean] True if the message should be signed def sign? settings.security[:logout_requests_signed] end + # Build the logout request XML document + # @return [Nokogiri::XML::Document] A XML document containing the request def build_xml_document Nokogiri::XML::Builder.new do |xml| xml['samlp'].LogoutRequest(compact_blank(xml_root_attributes)) do @@ -45,6 +58,8 @@ def build_xml_document end.doc end + # Returns the attributes for the NameID element + # @return [Hash] A hash of attributes for the NameID element def xml_nameid_attributes compact_blank!( 'NameQualifier' => settings.idp_name_qualifier, diff --git a/lib/ruby_saml/sp/builders/logout_response.rb b/lib/ruby_saml/sp/builders/logout_response.rb index 8413bead..64771d64 100644 --- a/lib/ruby_saml/sp/builders/logout_response.rb +++ b/lib/ruby_saml/sp/builders/logout_response.rb @@ -3,11 +3,23 @@ module RubySaml module Sp module Builders - # SAML LogoutResponse builder (SLO, IdP-initiated) + # SAML2.0 Logout Response (SLO SP-initiated) + # + # Creates a SAML LogoutResponse for Single Logout. + # The XML message is created and embedded into the HTTP-GET or HTTP-POST response + # according to the SAML Binding used. class LogoutResponse < MessageBuilder DEFAULT_STATUS_CODE = 'urn:oasis:names:tc:SAML:2.0:status:Success' DEFAULT_STATUS_MESSAGE = 'Successfully Signed Out' + # Creates a new LogoutResponse builder instance + # @param settings [RubySaml::Settings] Toolkit settings + # @param in_response_to [String] The ID of the LogoutRequest this response is for + # @param id [String|nil] ID for the response (if nil, one will be generated) + # @param relay_state [String|nil] RelayState parameter + # @param params [Hash|nil] Additional parameters + # @param status_code [String|nil] Status code for the response (default: Success) + # @param status_message [String|nil] Status message for the response (default: "Successfully Signed Out") def initialize(settings, in_response_to:, id: nil, relay_state: nil, params: nil, status_code: nil, status_message: nil) super(settings, id: id, relay_state: relay_state, params: nil) @in_response_to = in_response_to @@ -21,26 +33,35 @@ def initialize(settings, in_response_to:, id: nil, relay_state: nil, params: nil :status_code, :status_message + # Returns the message type for the response + # @return [String] The message type def message_type 'SAMLResponse' end # Determine the binding type from settings + # @return [String] The binding type def binding_type settings.idp_slo_service_binding end # Get the service URL from settings with validation + # @return [String] The IdP SLO URL for the response + # @raise [SettingError] if the URL is not set def service_url url = settings.idp_slo_response_service_url || settings.idp_slo_service_url raise SettingError.new "Invalid settings, IdP SLO service URL is not set!" if url.nil? || url.empty? url end + # Determines if the message should be signed + # @return [Boolean] True if the message should be signed def sign? settings.security[:logout_responses_signed] end + # Build the logout response XML document + # @return [Nokogiri::XML::Document] A XML document containing the response def build_xml_document Nokogiri::XML::Builder.new do |xml| xml['samlp'].LogoutResponse(xml_root_attributes) do @@ -54,6 +75,8 @@ def build_xml_document end.doc end + # Returns the attributes for the SAML root element + # @return [Hash] A hash of attributes for the SAML root element def xml_root_attributes hash = super hash['InResponseTo'] = in_response_to diff --git a/lib/ruby_saml/sp/builders/message_builder.rb b/lib/ruby_saml/sp/builders/message_builder.rb index 582197da..c732b4da 100644 --- a/lib/ruby_saml/sp/builders/message_builder.rb +++ b/lib/ruby_saml/sp/builders/message_builder.rb @@ -3,7 +3,19 @@ module RubySaml module Sp module Builders + # Base class for SAML message builders + # + # Provides common functionality for building SAML requests and responses: + # - URL construction for redirect and POST bindings + # - XML document creation + # - Message signing + # - Parameter handling class MessageBuilder + # Creates a new message builder instance + # @param settings [RubySaml::Settings] Toolkit settings + # @param id [String|nil] ID for the message (if nil, one will be generated) + # @param relay_state [String|nil] RelayState parameter + # @param params [Hash|nil] Additional parameters to include def initialize(settings, id: nil, relay_state: nil, params: nil) @settings = settings @id = id || generate_uuid @@ -11,23 +23,33 @@ def initialize(settings, id: nil, relay_state: nil, params: nil) @params = normalize_params(params) end + # Returns the full URL for the SAML message + # @return [String] URL for the SAML message def url binding_redirect? ? redirect_url : post_url end + # Returns the body for POST requests + # @return [Hash|nil] Body parameters for POST requests def body post_body unless binding_redirect? end + # Constructs the redirect URL with parameters + # @return [String] Full redirect URL with encoded parameters def redirect_url query_prefix = service_url.include?('?') ? '&' : '?' "#{service_url}#{query_prefix}#{URI.encode_www_form(build_payload(true))}" end memoize_method :redirect_url + # Alias for service_url, used with POST binding + # @return [String] Service URL for POST binding alias_method :post_url, :service_url memoize_method :post_url + # Builds the POST request body + # @return [Hash] POST request parameters def post_body build_payload(false) end @@ -37,8 +59,12 @@ def post_body attr_reader :settings, :id, - :relay_state + :relay_state, + :params + # Builds the payload for the SAML message + # @param redirect [Boolean] Whether to build for redirect binding + # @return [Hash] Parameters for the SAML message def build_payload(redirect) noko = build_xml_document sign_xml_document!(noko) unless redirect @@ -49,16 +75,21 @@ def build_payload(redirect) payload['RelayState'] = relay_state if relay_state if redirect && sign? && signing_key - params['SigAlg'] = signature_method - signed_params = url_encode(params.slice(message_type, 'RelayState', 'SigAlg')) + payload['SigAlg'] = signature_method + signed_params = URI.encode_www_form(payload.slice(message_type, 'RelayState', 'SigAlg')) signature = signing_key.sign(hash_algorithm.new, signed_params) - params['Signature'] = Base64.strict_encode64(signature) + payload['Signature'] = Base64.strict_encode64(signature) + end + + params.each do |key, value| + payload[key] = value unless payload.key?(key) end - payload.reverse_merge!(params) payload end + # Returns the attributes for the SAML root element + # @return [Hash] A hash of attributes for the SAML root element def xml_root_attributes compact_blank!( 'xmlns:samlp' => RubySaml::XML::NS_PROTOCOL, @@ -70,6 +101,9 @@ def xml_root_attributes ) end + # Signs the XML document + # @param noko [Nokogiri::XML::Document] The XML document to sign + # @return [Nokogiri::XML::Document] The signed XML document def sign_xml_document!(noko) RubySaml::XML::DocumentSigner.sign_document!( noko, @@ -80,39 +114,57 @@ def sign_xml_document!(noko) ) end + # Determines if the binding is redirect + # @return [Boolean] True if the binding is redirect def binding_redirect? binding_type == Utils::BINDINGS[:redirect] end + # Determines if the binding is POST + # @return [Boolean] True if the binding is POST def binding_post? !binding_redirect? end + # Returns the signing key + # @return [OpenSSL::PKey::RSA] The signing key def signing_key @signing_key ||= settings.get_sp_signing_key end + # Returns the signature method + # @return [String] The signature method def signature_method @signature_method ||= settings.sp_signature_method end + # Returns the hash algorithm + # @return [Class] The hash algorithm class def hash_algorithm @hash_algorithm ||= RubySaml::XML.hash_algorithm(signature_method) end + # Returns the digest method + # @return [String] The digest method def digest_method @digest_method ||= settings.get_sp_digest_method end - # Intentionally memoized + # Returns the UTC timestamp + # @return [String] The UTC timestamp def utc_timestamp @utc_timestamp ||= RubySaml::Utils.utc_timestamp end + # Generates a UUID + # @return [String] A generated UUID def generate_uuid RubySaml::Utils.generate_uuid(settings.sp_uuid_prefix) end + # Normalizes parameters + # @param params [Hash|nil] The parameters to normalize + # @return [Hash] Normalized parameters def normalize_params(params) (params || {}).to_h do |key, value| next if value.nil? || value.empty? @@ -121,11 +173,15 @@ def normalize_params(params) end end + # Removes blank values from a hash + # @param hash [Hash] The hash to clean + # @return [Hash] The hash with blank values removed def compact_blank!(hash) hash.reject! { |_, v| v.nil? || (v.respond_to?(:empty?) && v.empty?) } hash end + # Abstract methods that must be implemented by subclasses %i[message_type binding_type service_url sign? build_xml_document].each do |method_name| define_method(method_name) do raise NoMethodError.new("Subclass must implement #{method_name}") From a9ad99e5f57a8abc8cbfc98761be6dd6320cd4b7 Mon Sep 17 00:00:00 2001 From: johnnyshields <27655+johnnyshields@users.noreply.github.com> Date: Thu, 20 Mar 2025 14:16:28 +0900 Subject: [PATCH 6/7] More improvements --- lib/ruby_saml/sp/builders/authn_request.rb | 3 +++ lib/ruby_saml/sp/builders/logout_request.rb | 4 ++++ lib/ruby_saml/sp/builders/logout_response.rb | 2 ++ lib/ruby_saml/sp/builders/message_builder.rb | 4 +++- lib/ruby_saml/xml.rb | 1 + 5 files changed, 13 insertions(+), 1 deletion(-) diff --git a/lib/ruby_saml/sp/builders/authn_request.rb b/lib/ruby_saml/sp/builders/authn_request.rb index 19df298d..f993d7c9 100644 --- a/lib/ruby_saml/sp/builders/authn_request.rb +++ b/lib/ruby_saml/sp/builders/authn_request.rb @@ -48,6 +48,7 @@ def build_xml_document # Add Issuer element if sp_entity_id is present xml['saml'].Issuer(settings.sp_entity_id) if settings.sp_entity_id + # Add Subject element if name_identifier_value_requested is present if settings.name_identifier_value_requested xml['saml'].Subject do xml['saml'].NameID(settings.name_identifier_value_requested, xml_nameid_attributes) @@ -55,10 +56,12 @@ def build_xml_document end end + # Add NameIDPolicy element if name_identifier_format is present if settings.name_identifier_format xml['samlp'].NameIDPolicy(AllowCreate: 'true', Format: settings.name_identifier_format) end + # Add RequestedAuthnContext if authn_context or authn_context_decl_ref is present if settings.authn_context || settings.authn_context_decl_ref comparison = settings.authn_context_comparison || 'exact' diff --git a/lib/ruby_saml/sp/builders/logout_request.rb b/lib/ruby_saml/sp/builders/logout_request.rb index 8579f1e8..4630a840 100644 --- a/lib/ruby_saml/sp/builders/logout_request.rb +++ b/lib/ruby_saml/sp/builders/logout_request.rb @@ -44,15 +44,19 @@ def sign? def build_xml_document Nokogiri::XML::Builder.new do |xml| xml['samlp'].LogoutRequest(compact_blank(xml_root_attributes)) do + # Add Issuer element if sp_entity_id is present xml['saml'].Issuer(settings.sp_entity_id) if settings.sp_entity_id + # Add NameID element if settings.name_identifier_value xml['saml'].NameID(settings.name_identifier_value, xml_nameid_attributes) else + # If no NameID is present in the settings we generate one xml['saml'].NameID(RubySaml::Utils.generate_uuid, 'Format' => 'urn:oasis:names:tc:SAML:2.0:nameid-format:transient') end + # Add SessionIndex element if sessionindex is present xml['samlp'].SessionIndex(settings.sessionindex) if settings.sessionindex end end.doc diff --git a/lib/ruby_saml/sp/builders/logout_response.rb b/lib/ruby_saml/sp/builders/logout_response.rb index 64771d64..1116669a 100644 --- a/lib/ruby_saml/sp/builders/logout_response.rb +++ b/lib/ruby_saml/sp/builders/logout_response.rb @@ -65,8 +65,10 @@ def sign? def build_xml_document Nokogiri::XML::Builder.new do |xml| xml['samlp'].LogoutResponse(xml_root_attributes) do + # Add Issuer element if sp_entity_id is present xml['saml'].Issuer(settings.sp_entity_id) if settings.sp_entity_id + # Add Status section xml['samlp'].Status do xml['samlp'].StatusCode(Value: status_code) xml['samlp'].StatusMessage(status_message) diff --git a/lib/ruby_saml/sp/builders/message_builder.rb b/lib/ruby_saml/sp/builders/message_builder.rb index c732b4da..158b3f68 100644 --- a/lib/ruby_saml/sp/builders/message_builder.rb +++ b/lib/ruby_saml/sp/builders/message_builder.rb @@ -11,6 +11,8 @@ module Builders # - Message signing # - Parameter handling class MessageBuilder + include RubySaml::Memoizable + # Creates a new message builder instance # @param settings [RubySaml::Settings] Toolkit settings # @param id [String|nil] ID for the message (if nil, one will be generated) @@ -139,7 +141,7 @@ def signature_method end # Returns the hash algorithm - # @return [Class] The hash algorithm class + # @return [OpenSSL::Digest::Base] The hash algorithm class def hash_algorithm @hash_algorithm ||= RubySaml::XML.hash_algorithm(signature_method) end diff --git a/lib/ruby_saml/xml.rb b/lib/ruby_saml/xml.rb index 5fd47bf3..0ae50540 100644 --- a/lib/ruby_saml/xml.rb +++ b/lib/ruby_saml/xml.rb @@ -133,6 +133,7 @@ def signature_algorithm(element) end # Lookup XML digest hashing algorithm. + # @return [OpenSSL::Digest::Base] The hash algorithm class # @api private def hash_algorithm(element) alg = get_algorithm_attr(element) From 9c72fa560b0df3f0823e9fb401d34b744d97831c Mon Sep 17 00:00:00 2001 From: johnnyshields <27655+johnnyshields@users.noreply.github.com> Date: Thu, 20 Mar 2025 16:35:56 +0900 Subject: [PATCH 7/7] More WIP, potential API structure --- UPGRADING.md | 104 ++++++++++++++++++- lib/ruby_saml.rb | 5 + lib/ruby_saml/authrequest.rb | 68 ++++++++++++ lib/ruby_saml/logoutrequest.rb | 72 +++++++++++++ lib/ruby_saml/slo_logoutresponse.rb | 80 ++++++++++++++ lib/ruby_saml/sp/builders/authn_request.rb | 2 +- lib/ruby_saml/sp/builders/logout_request.rb | 2 +- lib/ruby_saml/sp/builders/logout_response.rb | 2 +- lib/ruby_saml/sp/builders/message_builder.rb | 42 +++----- test/authrequest_test.rb | 38 +++---- test/logoutrequest_test.rb | 30 +++--- test/slo_logoutresponse_test.rb | 34 +++--- 12 files changed, 395 insertions(+), 84 deletions(-) create mode 100644 lib/ruby_saml/authrequest.rb create mode 100644 lib/ruby_saml/logoutrequest.rb create mode 100644 lib/ruby_saml/slo_logoutresponse.rb diff --git a/UPGRADING.md b/UPGRADING.md index 17196a8c..a505eaef 100644 --- a/UPGRADING.md +++ b/UPGRADING.md @@ -1,6 +1,104 @@ # Ruby SAML Migration Guide -## Updating from 1.x to 2.0.0 +## Upgrading from 2.0.x to 2.1.0 + +**IMPORTANT: Please read this section carefully as it contains potentially breaking changes!** + +RubySaml 2.1.0 introduces a greatly simplified API and class-based errors. + +We have attempted our best to "shim" the old functionality and methods to the new API, +in such a way that all tests pass and the gem should work as before. However, there are +a few minor changes to be aware. + +### Before upgrading + +Please ensure you have first upgraded to latest 2.0.x, and that it is running +smoothly in production. Refer to "Upgrading from 1.x to 2.0.0" below. + +### Deprecation of SP message builder classes (`Authrequest`, `Logoutrequest`, `SloLogoutresponse`) + +| Old Class | New Class | +|--------------------------------|------------------------------------------| +| `RubySaml::Authrequest` | `RubySaml::Builders::SP::AuthnRequest` | +| `RubySaml::Logoutrequest` | `RubySaml::Builders::SP::LogoutRequest` | +| `RubySaml::SloLogoutresponse` | `RubySaml::Builders::SP::LogoutResponse` | + +For each of these, the method usage has changed: + +| Old Method | New Method | +|------------------------|-------------------------------------------| +| `#create` | `#url` (or `#redirect_url` / `#post_url`) | +| `#create_params` | `#body` (or `#post_body`) | +| `#create_xml_document` | `#xml` | + +### Deprecation of IdP message parser classes (`Response`, `Logoutresponse`, `SloLogoutrequest`) + +| Old Class | New Class | +|------------------------------|------------------------------------------| +| `RubySaml::Response` | `RubySaml::Parsers::IdP::Response` | +| `RubySaml::Logoutresponse` | `RubySaml::Parsers::IdP::LogoutResponse` | +| `RubySaml::SloLogoutrequest` | `RubySaml::Parsers::IdP::LogoutRequest` | + + +### Deprecation of metadata-related classes + +| Old Class | New Class | +|-------------------------------|------------------------------------| +| `RubySaml::Metadata` | `RubySaml::Builders::SP::Metadata` | +| `RubySaml::IdpMetadataParser` | `RubySaml::Parsers::IdP::Metadata` | + + +### New Shortcut API + +```ruby +app = RubySaml::SPApplication(settings) + +# Create your RubySaml::Builder::SP::AuthnRequest object +app.build('AuthnRequest', **options) + +app.parse('Response', params) +``` + + +```ruby +class MySamlController < ActionController::Base + def index + sp_app = RubySaml::SPApplication(settings) + authn = sp_app.build('AuthnRequest', **options) + + if authn.binding_post? + @saml_message = authn + render 'saml_post_form' + else + redirect_to authn.redirect_url, allow_other_host: true + end + end +end +``` + +```html + + +``` + + +ap + +authn.url +authn.url + + +RubySaml::Application(settings).sp.build('AuthnRequest', **options) + + + + +## Upgrading from 1.x to 2.0.0 **IMPORTANT: Please read this section carefully as it contains breaking changes!** @@ -212,7 +310,7 @@ and `#format_private_key` methods. Specifically: stripped out. - Case 7: If no valid certificates are found, the entire original string will be returned. -## Updating from 1.17.x to 1.18.0 +## Upgrading from 1.17.x to 1.18.0 Version `1.18.0` changes the way the toolkit validates SAML signatures. There is a new order how validation happens in the toolkit and also the toolkit by default will check malformed doc @@ -222,7 +320,7 @@ The SignedDocument class defined at xml_security.rb experienced several changes. We don't expect compatibilty issues if you use the main methods offered by ruby-saml, but if you use a fork or customized usage, is possible that you need to adapt your code. -## Updating from 1.12.x to 1.13.0 +## Upgrading from 1.12.x to 1.13.0 Version `1.13.0` adds `settings.idp_sso_service_binding` and `settings.idp_slo_service_binding`, and deprecates `settings.security[:embed_sign]`. If specified, new binding parameters will be used in place of `:embed_sign` diff --git a/lib/ruby_saml.rb b/lib/ruby_saml.rb index 3ca95998..f9999ffe 100644 --- a/lib/ruby_saml.rb +++ b/lib/ruby_saml.rb @@ -10,6 +10,11 @@ require 'ruby_saml/logging' require 'ruby_saml/xml' require 'ruby_saml/settings' +require 'ruby_saml/memoizable' + +require 'ruby_saml/authrequest' +require 'ruby_saml/logoutrequest' +require 'ruby_saml/slo_logoutresponse' require 'ruby_saml/sp/builders/message_builder' require 'ruby_saml/sp/builders/authn_request' diff --git a/lib/ruby_saml/authrequest.rb b/lib/ruby_saml/authrequest.rb new file mode 100644 index 00000000..40b98fc1 --- /dev/null +++ b/lib/ruby_saml/authrequest.rb @@ -0,0 +1,68 @@ +# frozen_string_literal: true + +require "ruby_saml/logging" +require "ruby_saml/saml_message" +require "ruby_saml/utils" +require "ruby_saml/setting_error" + +module RubySaml + # SAML2 Authentication. AuthnRequest (SSO SP initiated) + # + # Shim class that delegates to RubySaml::Sp::Builders::AuthnRequest + class Authrequest < SamlMessage + # AuthnRequest ID + attr_accessor :uuid + alias_method :request_id, :uuid + + # Creates the AuthnRequest string. + # @param settings [RubySaml::Settings|nil] Toolkit settings + # @param params [Hash] Some extra parameters to be added in the GET for example the RelayState + # @return [String] AuthnRequest string that includes the SAMLRequest + def create(settings, params = {}) + create_builder(settings, params) + @login_url = builder.url + end + + # Creates the Get parameters for the request. + # @param settings [RubySaml::Settings|nil] Toolkit settings + # @param params [Hash] Some extra parameters to be added in the GET for example the RelayState + # @return [Hash] Parameters + def create_params(settings, params={}) + create_builder(settings, params) + is_redirect = settings.idp_sso_service_binding == Utils::BINDINGS[:redirect] + + # Log the request + request_doc = builder.send(:build_xml_document) + request = request_doc.to_xml(save_with: Nokogiri::XML::Node::SaveOptions::AS_XML) + Logging.debug "Created AuthnRequest: #{request}" + + # Get payload parameters + builder.send(:build_payload, is_redirect) + end + + # Creates the SAMLRequest String. + # @param settings [RubySaml::Settings|nil] Toolkit settings + # @return [String] The SAMLRequest String. + def create_authentication_xml_doc(settings, params = nil) + create_builder(settings, params) + builder.send(:build_xml_document) + end + + def sign_document(noko, _settings = nil) + builder.send(:sign_xml_document!, noko) + end + + private + + attr_reader :builder + + def create_builder(settings, params = {}) + @uuid ||= RubySaml::Utils.generate_uuid(settings.sp_uuid_prefix) + @builder ||= RubySaml::Sp::Builders::AuthnRequest.new( + settings, + id: @uuid, + params: params + ) + end + end +end diff --git a/lib/ruby_saml/logoutrequest.rb b/lib/ruby_saml/logoutrequest.rb new file mode 100644 index 00000000..39c25f05 --- /dev/null +++ b/lib/ruby_saml/logoutrequest.rb @@ -0,0 +1,72 @@ +# frozen_string_literal: true + +require "ruby_saml/logging" +require "ruby_saml/saml_message" +require "ruby_saml/utils" +require "ruby_saml/setting_error" + +module RubySaml + # SAML2 Logout Request (SLO SP initiated) + # + # Shim class that delegates to RubySaml::Sp::Builders::LogoutRequest + class Logoutrequest < SamlMessage + # Logout Request ID + attr_accessor :uuid + alias_method :request_id, :uuid + + # Creates the Logout Request string. + # @param settings [RubySaml::Settings|nil] Toolkit settings + # @param params [Hash] Some extra parameters to be added in the GET for example the RelayState + # @return [String] Logout Request string that includes the SAMLRequest + def create(settings, params = {}) + create_builder(settings, params) + @logout_url = builder.url + end + + # Creates the Get parameters for the logout request. + # @param settings [RubySaml::Settings|nil] Toolkit settings + # @param params [Hash] Some extra parameters to be added in the GET for example the RelayState + # @return [Hash] Parameters + def create_params(settings, params = {}) + create_builder(settings, params) + + request_doc = builder.send(:build_xml_document) + request = request_doc.to_xml(save_with: Nokogiri::XML::Node::SaveOptions::AS_XML) + Logging.debug "Created SLO Logout Request: #{request}" + + is_redirect = settings.idp_slo_service_binding == Utils::BINDINGS[:redirect] + builder.send(:build_payload, is_redirect) + end + + def create_logout_request_xml_doc(settings) + create_builder(settings) + noko = builder.send(:build_xml_document) + sign_document(noko) + # is_redirect = settings.idp_slo_service_binding == Utils::BINDINGS[:redirect] + # sign_document(noko) unless is_redirect + # noko + end + + def create_xml_document(settings) + create_builder(settings) + builder.send(:build_xml_document) + end + + def sign_document(noko, _settings = nil) + builder.send(:sign_xml_document!, noko) + end + + private + + attr_reader :builder + + def create_builder(settings, params = {}) + @uuid ||= RubySaml::Utils.generate_uuid(settings.sp_uuid_prefix) + @builder ||= RubySaml::Sp::Builders::LogoutRequest.new( + settings, + id: @uuid, + params: params + ) + end + end +end diff --git a/lib/ruby_saml/slo_logoutresponse.rb b/lib/ruby_saml/slo_logoutresponse.rb new file mode 100644 index 00000000..73dbb79e --- /dev/null +++ b/lib/ruby_saml/slo_logoutresponse.rb @@ -0,0 +1,80 @@ +# frozen_string_literal: true + +require "ruby_saml/logging" +require "ruby_saml/saml_message" +require "ruby_saml/utils" +require "ruby_saml/setting_error" + +module RubySaml + # SAML2 Logout Response (SLO SP initiated) + # + # Shim class that delegates to RubySaml::Sp::Builders::LogoutResponse + class SloLogoutresponse < SamlMessage + # Logout Response ID + attr_accessor :uuid + alias_method :response_id, :uuid + + # Creates the Logout Response string. + # @param settings [RubySaml::Settings|nil] Toolkit settings + # @param request_id [String] The ID of the LogoutRequest sent by this SP to the IdP. That ID will be placed as the InResponseTo in the logout response + # @param logout_message [String] The Message to be placed as StatusMessage in the logout response + # @param params [Hash] Some extra parameters to be added in the GET for example the RelayState + # @param logout_status_code [String] The StatusCode to be placed as StatusMessage in the logout response + # @return [String] Logout Request string that includes the SAMLRequest + def create(settings, request_id = nil, logout_message = nil, params = {}, logout_status_code = nil) + create_builder(settings, request_id, logout_message, params, logout_status_code) + @logout_url = builder.url + end + + # Creates the Get parameters for the logout response. + # @param settings [RubySaml::Settings|nil] Toolkit settings + # @param request_id [String] The ID of the LogoutRequest sent by this SP to the IdP. That ID will be placed as the InResponseTo in the logout response + # @param logout_message [String] The Message to be placed as StatusMessage in the logout response + # @param params [Hash] Some extra parameters to be added in the GET for example the RelayState + # @param logout_status_code [String] The StatusCode to be placed as StatusMessage in the logout response + # @return [Hash] Parameters + def create_params(settings, request_id = nil, logout_message = nil, params = {}, logout_status_code = nil) + create_builder(settings, request_id, logout_message, params, logout_status_code) + is_redirect = settings.idp_slo_service_binding == Utils::BINDINGS[:redirect] + + # Log the response + response_doc = builder.send(:build_xml_document) + response = response_doc.to_xml(save_with: Nokogiri::XML::Node::SaveOptions::AS_XML) + Logging.debug "Created SLO Logout Response: #{response}" + + # Get payload parameters + builder.send(:build_payload, is_redirect) + end + + def create_logout_response_xml_doc(settings, request_id = nil, logout_message = nil, status_code = nil) + create_builder(settings, request_id, logout_message, {}, status_code) + noko = builder.send(:build_xml_document) + sign_document(noko) # TODO: unless redirect + end + + def create_xml_document(settings, request_id = nil, logout_message = nil, status_code = nil) + create_builder(settings, request_id, logout_message, {}, status_code) + builder.send(:build_xml_document) + end + + def sign_document(noko, _settings = nil) + builder.send(:sign_xml_document!, noko) + end + + private + + attr_reader :builder + + def create_builder(settings, request_id = nil, logout_message = nil, params = {}, logout_status_code = nil) + @uuid ||= RubySaml::Utils.generate_uuid(settings.sp_uuid_prefix) + @builder ||= RubySaml::Sp::Builders::LogoutResponse.new( + settings, + id: @uuid, + in_response_to: request_id, + params: params, + status_code: logout_status_code, + status_message: logout_message + ) + end + end +end diff --git a/lib/ruby_saml/sp/builders/authn_request.rb b/lib/ruby_saml/sp/builders/authn_request.rb index f993d7c9..dde6be81 100644 --- a/lib/ruby_saml/sp/builders/authn_request.rb +++ b/lib/ruby_saml/sp/builders/authn_request.rb @@ -43,7 +43,7 @@ def sign? # @return [Nokogiri::XML::Document] A XML document containing the request def build_xml_document Nokogiri::XML::Builder.new do |xml| - xml['samlp'].AuthnRequest(compact_blank(xml_root_attributes)) do + xml['samlp'].AuthnRequest(xml_root_attributes) do # Add Issuer element if sp_entity_id is present xml['saml'].Issuer(settings.sp_entity_id) if settings.sp_entity_id diff --git a/lib/ruby_saml/sp/builders/logout_request.rb b/lib/ruby_saml/sp/builders/logout_request.rb index 4630a840..5ed48821 100644 --- a/lib/ruby_saml/sp/builders/logout_request.rb +++ b/lib/ruby_saml/sp/builders/logout_request.rb @@ -43,7 +43,7 @@ def sign? # @return [Nokogiri::XML::Document] A XML document containing the request def build_xml_document Nokogiri::XML::Builder.new do |xml| - xml['samlp'].LogoutRequest(compact_blank(xml_root_attributes)) do + xml['samlp'].LogoutRequest(xml_root_attributes) do # Add Issuer element if sp_entity_id is present xml['saml'].Issuer(settings.sp_entity_id) if settings.sp_entity_id diff --git a/lib/ruby_saml/sp/builders/logout_response.rb b/lib/ruby_saml/sp/builders/logout_response.rb index 1116669a..390acfcc 100644 --- a/lib/ruby_saml/sp/builders/logout_response.rb +++ b/lib/ruby_saml/sp/builders/logout_response.rb @@ -21,7 +21,7 @@ class LogoutResponse < MessageBuilder # @param status_code [String|nil] Status code for the response (default: Success) # @param status_message [String|nil] Status message for the response (default: "Successfully Signed Out") def initialize(settings, in_response_to:, id: nil, relay_state: nil, params: nil, status_code: nil, status_message: nil) - super(settings, id: id, relay_state: relay_state, params: nil) + super(settings, id: id, relay_state: relay_state, params: params) @in_response_to = in_response_to @status_code = status_code || DEFAULT_STATUS_CODE @status_message = status_message || DEFAULT_STATUS_MESSAGE diff --git a/lib/ruby_saml/sp/builders/message_builder.rb b/lib/ruby_saml/sp/builders/message_builder.rb index 158b3f68..ba1dfce2 100644 --- a/lib/ruby_saml/sp/builders/message_builder.rb +++ b/lib/ruby_saml/sp/builders/message_builder.rb @@ -22,7 +22,7 @@ def initialize(settings, id: nil, relay_state: nil, params: nil) @settings = settings @id = id || generate_uuid @relay_state = relay_state - @params = normalize_params(params) + @params = params end # Returns the full URL for the SAML message @@ -47,7 +47,9 @@ def redirect_url # Alias for service_url, used with POST binding # @return [String] Service URL for POST binding - alias_method :post_url, :service_url + def post_url + service_url + end memoize_method :post_url # Builds the POST request body @@ -55,7 +57,7 @@ def redirect_url def post_body build_payload(false) end - memoize_method :post_params + memoize_method :post_body private @@ -67,26 +69,24 @@ def post_body # Builds the payload for the SAML message # @param redirect [Boolean] Whether to build for redirect binding # @return [Hash] Parameters for the SAML message - def build_payload(redirect) + def build_payload(is_redirect) noko = build_xml_document - sign_xml_document!(noko) unless redirect + sign_xml_document!(noko) unless is_redirect message_data = noko.to_xml(save_with: Nokogiri::XML::Node::SaveOptions::AS_XML) - message_data = RubySaml::XML::Decoder.encode_message(message_data, compress: redirect) + message_data = RubySaml::XML::Decoder.encode_message(message_data, compress: is_redirect) payload = { message_type => message_data } payload['RelayState'] = relay_state if relay_state + params.each { |key, value| payload[key.to_s] ||= value.to_s } + payload.delete('RelayState') if payload['RelayState'].nil? || payload['RelayState'].empty? - if redirect && sign? && signing_key + if is_redirect && sign? && signing_key payload['SigAlg'] = signature_method - signed_params = URI.encode_www_form(payload.slice(message_type, 'RelayState', 'SigAlg')) - signature = signing_key.sign(hash_algorithm.new, signed_params) + params_to_sign = URI.encode_www_form(payload.slice(message_type, 'RelayState', 'SigAlg')) + signature = signing_key.sign(hash_algorithm.new, params_to_sign) payload['Signature'] = Base64.strict_encode64(signature) end - params.each do |key, value| - payload[key] = value unless payload.key?(key) - end - payload end @@ -107,6 +107,9 @@ def xml_root_attributes # @param noko [Nokogiri::XML::Document] The XML document to sign # @return [Nokogiri::XML::Document] The signed XML document def sign_xml_document!(noko) + cert, private_key = settings.get_sp_signing_pair + return unless cert && private_key + RubySaml::XML::DocumentSigner.sign_document!( noko, private_key, @@ -137,7 +140,7 @@ def signing_key # Returns the signature method # @return [String] The signature method def signature_method - @signature_method ||= settings.sp_signature_method + @signature_method ||= settings.get_sp_signature_method end # Returns the hash algorithm @@ -164,17 +167,6 @@ def generate_uuid RubySaml::Utils.generate_uuid(settings.sp_uuid_prefix) end - # Normalizes parameters - # @param params [Hash|nil] The parameters to normalize - # @return [Hash] Normalized parameters - def normalize_params(params) - (params || {}).to_h do |key, value| - next if value.nil? || value.empty? - - [key.to_s, value.to_s] - end - end - # Removes blank values from a hash # @param hash [Hash] The hash to clean # @return [Hash] The hash with blank values removed diff --git a/test/authrequest_test.rb b/test/authrequest_test.rb index 63bd2b5b..bb542aaf 100644 --- a/test/authrequest_test.rb +++ b/test/authrequest_test.rb @@ -38,14 +38,10 @@ class AuthrequestTest < Minitest::Test assert_match(/ nil }) + auth_url = RubySaml::Authrequest.new.create(settings, { 'RelayState' => nil }) assert !auth_url.include?('RelayState') - auth_url = RubySaml::Authrequest.new.create(settings, { :RelayState => "http://example.com" }) + auth_url = RubySaml::Authrequest.new.create(settings, { 'RelayState' => "http://example.com" }) assert auth_url.include?('&RelayState=http%3A%2F%2Fexample.com') auth_url = RubySaml::Authrequest.new.create(settings, { 'RelayState' => nil }) @@ -410,15 +406,15 @@ class AuthrequestTest < Minitest::Test end it "create a signature parameter and validate it" do - params = RubySaml::Authrequest.new.create_params(settings, :RelayState => 'http://example.com') + params = RubySaml::Authrequest.new.create_params(settings, 'RelayState' => 'http://example.com') assert params['SAMLRequest'] - assert params[:RelayState] + assert params['RelayState'] assert params['Signature'] assert_equal params['SigAlg'], signature_method(sp_key_algo, sp_hash_algo) query_string = "SAMLRequest=#{CGI.escape(params['SAMLRequest'])}" - query_string << "&RelayState=#{CGI.escape(params[:RelayState])}" + query_string << "&RelayState=#{CGI.escape(params['RelayState'])}" query_string << "&SigAlg=#{CGI.escape(params['SigAlg'])}" assert @cert.public_key.verify(RubySaml::XML.hash_algorithm(params['SigAlg']).new, Base64.decode64(params['Signature']), query_string) @@ -428,15 +424,15 @@ class AuthrequestTest < Minitest::Test it 'using mixed signature and digest methods (signature SHA256)' do # RSA is ignored here; only the hash sp_key_algo is used settings.security[:signature_method] = RubySaml::XML::RSA_SHA256 - params = RubySaml::Authrequest.new.create_params(settings, :RelayState => 'http://example.com') + params = RubySaml::Authrequest.new.create_params(settings, 'RelayState' => 'http://example.com') assert params['SAMLRequest'] - assert params[:RelayState] + assert params['RelayState'] assert params['Signature'] assert_equal params['SigAlg'], signature_method(sp_key_algo, :sha256) query_string = "SAMLRequest=#{CGI.escape(params['SAMLRequest'])}" - query_string << "&RelayState=#{CGI.escape(params[:RelayState])}" + query_string << "&RelayState=#{CGI.escape(params['RelayState'])}" query_string << "&SigAlg=#{CGI.escape(params['SigAlg'])}" assert @cert.public_key.verify(RubySaml::XML.hash_algorithm(params['SigAlg']).new, Base64.decode64(params['Signature']), query_string) @@ -444,15 +440,15 @@ class AuthrequestTest < Minitest::Test it 'using mixed signature and digest methods (digest SHA256)' do settings.security[:digest_method] = RubySaml::XML::SHA256 - params = RubySaml::Authrequest.new.create_params(settings, :RelayState => 'http://example.com') + params = RubySaml::Authrequest.new.create_params(settings, 'RelayState' => 'http://example.com') assert params['SAMLRequest'] - assert params[:RelayState] + assert params['RelayState'] assert params['Signature'] assert_equal params['SigAlg'], signature_method(sp_key_algo, sp_hash_algo) query_string = "SAMLRequest=#{CGI.escape(params['SAMLRequest'])}" - query_string << "&RelayState=#{CGI.escape(params[:RelayState])}" + query_string << "&RelayState=#{CGI.escape(params['RelayState'])}" query_string << "&SigAlg=#{CGI.escape(params['SigAlg'])}" assert @cert.public_key.verify(RubySaml::XML.hash_algorithm(params['SigAlg']).new, Base64.decode64(params['Signature']), query_string) @@ -471,14 +467,14 @@ class AuthrequestTest < Minitest::Test ] } - params = RubySaml::Authrequest.new.create_params(settings, :RelayState => 'http://example.com') + params = RubySaml::Authrequest.new.create_params(settings, 'RelayState' => 'http://example.com') assert params['SAMLRequest'] - assert params[:RelayState] + assert params['RelayState'] assert params['Signature'] assert_equal params['SigAlg'], signature_method(sp_key_algo, :sha1) query_string = "SAMLRequest=#{CGI.escape(params['SAMLRequest'])}" - query_string << "&RelayState=#{CGI.escape(params[:RelayState])}" + query_string << "&RelayState=#{CGI.escape(params['RelayState'])}" query_string << "&SigAlg=#{CGI.escape(params['SigAlg'])}" signature_algorithm = RubySaml::XML.hash_algorithm(params['SigAlg']) @@ -491,7 +487,7 @@ class AuthrequestTest < Minitest::Test settings.security[:check_sp_cert_expiration] = true assert_raises(RubySaml::ValidationError, 'The SP certificate expired.') do - RubySaml::Authrequest.new.create_params(settings, :RelayState => 'http://example.com') + RubySaml::Authrequest.new.create_params(settings, 'RelayState' => 'http://example.com') end end end diff --git a/test/logoutrequest_test.rb b/test/logoutrequest_test.rb index 2230257b..6cd9a561 100644 --- a/test/logoutrequest_test.rb +++ b/test/logoutrequest_test.rb @@ -29,10 +29,10 @@ class RequestTest < Minitest::Test end it "RelayState cases" do - unauth_url = RubySaml::Logoutrequest.new.create(settings, { :RelayState => nil }) + unauth_url = RubySaml::Logoutrequest.new.create(settings, { 'RelayState' => nil }) assert !unauth_url.include?('RelayState') - unauth_url = RubySaml::Logoutrequest.new.create(settings, { :RelayState => "http://example.com" }) + unauth_url = RubySaml::Logoutrequest.new.create(settings, { 'RelayState' => "http://example.com" }) assert unauth_url.include?('&RelayState=http%3A%2F%2Fexample.com') unauth_url = RubySaml::Logoutrequest.new.create(settings, { 'RelayState' => nil }) @@ -277,15 +277,15 @@ class RequestTest < Minitest::Test end it "creates a signature parameter and validate it" do - params = RubySaml::Logoutrequest.new.create_params(settings, :RelayState => 'http://example.com') + params = RubySaml::Logoutrequest.new.create_params(settings, 'RelayState' => 'http://example.com') assert params['SAMLRequest'] - assert params[:RelayState] + assert params['RelayState'] assert params['Signature'] assert_equal params['SigAlg'], signature_method(sp_key_algo, sp_hash_algo) query_string = "SAMLRequest=#{CGI.escape(params['SAMLRequest'])}" - query_string << "&RelayState=#{CGI.escape(params[:RelayState])}" + query_string << "&RelayState=#{CGI.escape(params['RelayState'])}" query_string << "&SigAlg=#{CGI.escape(params['SigAlg'])}" assert @cert.public_key.verify(RubySaml::XML.hash_algorithm(params['SigAlg']).new, Base64.decode64(params['Signature']), query_string) @@ -295,15 +295,15 @@ class RequestTest < Minitest::Test it 'using mixed signature and digest methods (signature SHA256)' do # RSA is ignored here; only the hash sp_key_algo is used settings.security[:signature_method] = RubySaml::XML::RSA_SHA256 - params = RubySaml::Logoutrequest.new.create_params(settings, :RelayState => 'http://example.com') + params = RubySaml::Logoutrequest.new.create_params(settings, 'RelayState' => 'http://example.com') assert params['SAMLRequest'] - assert params[:RelayState] + assert params['RelayState'] assert params['Signature'] assert_equal params['SigAlg'], signature_method(sp_key_algo, :sha256) query_string = "SAMLRequest=#{CGI.escape(params['SAMLRequest'])}" - query_string << "&RelayState=#{CGI.escape(params[:RelayState])}" + query_string << "&RelayState=#{CGI.escape(params['RelayState'])}" query_string << "&SigAlg=#{CGI.escape(params['SigAlg'])}" assert @cert.public_key.verify(RubySaml::XML.hash_algorithm(params['SigAlg']).new, Base64.decode64(params['Signature']), query_string) @@ -311,15 +311,15 @@ class RequestTest < Minitest::Test it 'using mixed signature and digest methods (digest SHA256)' do settings.security[:digest_method] = RubySaml::XML::SHA256 - params = RubySaml::Logoutrequest.new.create_params(settings, :RelayState => 'http://example.com') + params = RubySaml::Logoutrequest.new.create_params(settings, 'RelayState' => 'http://example.com') assert params['SAMLRequest'] - assert params[:RelayState] + assert params['RelayState'] assert params['Signature'] assert_equal params['SigAlg'], signature_method(sp_key_algo, sp_hash_algo) query_string = "SAMLRequest=#{CGI.escape(params['SAMLRequest'])}" - query_string << "&RelayState=#{CGI.escape(params[:RelayState])}" + query_string << "&RelayState=#{CGI.escape(params['RelayState'])}" query_string << "&SigAlg=#{CGI.escape(params['SigAlg'])}" assert @cert.public_key.verify(RubySaml::XML.hash_algorithm(params['SigAlg']).new, Base64.decode64(params['Signature']), query_string) @@ -336,15 +336,15 @@ class RequestTest < Minitest::Test CertificateHelper.generate_pem_hash ] } - params = RubySaml::Logoutrequest.new.create_params(settings, :RelayState => 'http://example.com') + params = RubySaml::Logoutrequest.new.create_params(settings, 'RelayState' => 'http://example.com') assert params['SAMLRequest'] - assert params[:RelayState] + assert params['RelayState'] assert params['Signature'] assert_equal params['SigAlg'], signature_method(sp_key_algo, sp_hash_algo) query_string = "SAMLRequest=#{CGI.escape(params['SAMLRequest'])}" - query_string << "&RelayState=#{CGI.escape(params[:RelayState])}" + query_string << "&RelayState=#{CGI.escape(params['RelayState'])}" query_string << "&SigAlg=#{CGI.escape(params['SigAlg'])}" assert cert.public_key.verify(RubySaml::XML.hash_algorithm(params['SigAlg']).new, Base64.decode64(params['Signature']), query_string) @@ -355,7 +355,7 @@ class RequestTest < Minitest::Test settings.security[:check_sp_cert_expiration] = true assert_raises(RubySaml::ValidationError, 'The SP certificate expired.') do - RubySaml::Logoutrequest.new.create_params(settings, :RelayState => 'http://example.com') + RubySaml::Logoutrequest.new.create_params(settings, 'RelayState' => 'http://example.com') end end end diff --git a/test/slo_logoutresponse_test.rb b/test/slo_logoutresponse_test.rb index a4c217db..6355fa08 100644 --- a/test/slo_logoutresponse_test.rb +++ b/test/slo_logoutresponse_test.rb @@ -32,15 +32,15 @@ class SloLogoutresponseTest < Minitest::Test unauth_url = RubySaml::SloLogoutresponse.new.create(settings, logout_request.id, nil, { :foo => "bar" }) assert_match(/&foo=bar$/, unauth_url) - unauth_url = RubySaml::SloLogoutresponse.new.create(settings, logout_request.id, nil, { :RelayState => "http://idp.example.com" }) - assert_match(/&RelayState=http%3A%2F%2Fidp.example.com$/, unauth_url) + unauth_url = RubySaml::SloLogoutresponse.new.create(settings, logout_request.id, nil, { 'RelayState' => "http://idp.example.com" }) + assert_match(/&RelayState=http%3A%2F%2Fidp\.example\.com$/, unauth_url) end it "RelayState cases" do - unauth_url = RubySaml::SloLogoutresponse.new.create(settings, logout_request.id, nil, { :RelayState => nil }) + unauth_url = RubySaml::SloLogoutresponse.new.create(settings, logout_request.id, nil, { 'RelayState' => nil }) assert !unauth_url.include?('RelayState') - unauth_url = RubySaml::SloLogoutresponse.new.create(settings, logout_request.id, nil, { :RelayState => "http://example.com" }) + unauth_url = RubySaml::SloLogoutresponse.new.create(settings, logout_request.id, nil, { 'RelayState' => "http://example.com" }) assert unauth_url.include?('&RelayState=http%3A%2F%2Fexample.com') unauth_url = RubySaml::SloLogoutresponse.new.create(settings, logout_request.id, nil, { 'RelayState' => nil }) @@ -271,15 +271,15 @@ class SloLogoutresponseTest < Minitest::Test end it "creates a signature parameter and validate it" do - params = RubySaml::SloLogoutresponse.new.create_params(settings, logout_request.id, "Custom Logout Message", :RelayState => 'http://example.com') + params = RubySaml::SloLogoutresponse.new.create_params(settings, logout_request.id, "Custom Logout Message", 'RelayState' => 'http://example.com') assert params['SAMLResponse'] - assert params[:RelayState] + assert params['RelayState'] assert params['Signature'] assert_equal params['SigAlg'], signature_method(sp_key_algo, sp_hash_algo) query_string = "SAMLResponse=#{CGI.escape(params['SAMLResponse'])}" - query_string << "&RelayState=#{CGI.escape(params[:RelayState])}" + query_string << "&RelayState=#{CGI.escape(params['RelayState'])}" query_string << "&SigAlg=#{CGI.escape(params['SigAlg'])}" assert @cert.public_key.verify(RubySaml::XML.hash_algorithm(params['SigAlg']).new, Base64.decode64(params['Signature']), query_string) @@ -290,15 +290,15 @@ class SloLogoutresponseTest < Minitest::Test # RSA is ignored here; only the hash sp_key_algo is used settings.security[:signature_method] = RubySaml::XML::RSA_SHA256 logout_request.settings = settings - params = RubySaml::SloLogoutresponse.new.create_params(settings, logout_request.id, "Custom Logout Message", :RelayState => 'http://example.com') + params = RubySaml::SloLogoutresponse.new.create_params(settings, logout_request.id, "Custom Logout Message", 'RelayState' => 'http://example.com') assert params['SAMLResponse'] - assert params[:RelayState] + assert params['RelayState'] assert params['Signature'] assert_equal params['SigAlg'], signature_method(sp_key_algo, :sha256) query_string = "SAMLResponse=#{CGI.escape(params['SAMLResponse'])}" - query_string << "&RelayState=#{CGI.escape(params[:RelayState])}" + query_string << "&RelayState=#{CGI.escape(params['RelayState'])}" query_string << "&SigAlg=#{CGI.escape(params['SigAlg'])}" assert @cert.public_key.verify(RubySaml::XML.hash_algorithm(params['SigAlg']).new, Base64.decode64(params['Signature']), query_string) @@ -307,15 +307,15 @@ class SloLogoutresponseTest < Minitest::Test it 'using mixed signature and digest methods (digest SHA256)' do settings.security[:digest_method] = RubySaml::XML::SHA256 logout_request.settings = settings - params = RubySaml::SloLogoutresponse.new.create_params(settings, logout_request.id, "Custom Logout Message", :RelayState => 'http://example.com') + params = RubySaml::SloLogoutresponse.new.create_params(settings, logout_request.id, "Custom Logout Message", 'RelayState' => 'http://example.com') assert params['SAMLResponse'] - assert params[:RelayState] + assert params['RelayState'] assert params['Signature'] assert_equal params['SigAlg'], signature_method(sp_key_algo, sp_hash_algo) query_string = "SAMLResponse=#{CGI.escape(params['SAMLResponse'])}" - query_string << "&RelayState=#{CGI.escape(params[:RelayState])}" + query_string << "&RelayState=#{CGI.escape(params['RelayState'])}" query_string << "&SigAlg=#{CGI.escape(params['SigAlg'])}" assert @cert.public_key.verify(RubySaml::XML.hash_algorithm(params['SigAlg']).new, Base64.decode64(params['Signature']), query_string) @@ -332,15 +332,15 @@ class SloLogoutresponseTest < Minitest::Test CertificateHelper.generate_pem_hash ] } - params = RubySaml::SloLogoutresponse.new.create_params(settings, logout_request.id, "Custom Logout Message", :RelayState => 'http://example.com') + params = RubySaml::SloLogoutresponse.new.create_params(settings, logout_request.id, "Custom Logout Message", 'RelayState' => 'http://example.com') assert params['SAMLResponse'] - assert params[:RelayState] + assert params['RelayState'] assert params['Signature'] assert_equal params['SigAlg'], signature_method(sp_key_algo, sp_hash_algo) query_string = "SAMLResponse=#{CGI.escape(params['SAMLResponse'])}" - query_string << "&RelayState=#{CGI.escape(params[:RelayState])}" + query_string << "&RelayState=#{CGI.escape(params['RelayState'])}" query_string << "&SigAlg=#{CGI.escape(params['SigAlg'])}" assert cert.public_key.verify(RubySaml::XML.hash_algorithm(params['SigAlg']).new, Base64.decode64(params['Signature']), query_string) @@ -351,7 +351,7 @@ class SloLogoutresponseTest < Minitest::Test settings.security[:check_sp_cert_expiration] = true assert_raises(RubySaml::ValidationError, 'The SP certificate expired.') do - RubySaml::SloLogoutresponse.new.create_params(settings, logout_request.id, "Custom Logout Message", :RelayState => 'http://example.com') + RubySaml::SloLogoutresponse.new.create_params(settings, logout_request.id, "Custom Logout Message", 'RelayState' => 'http://example.com') end end end