diff --git a/CHANGELOG.md b/CHANGELOG.md index f62db877..ccc62a3b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -119,7 +119,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 e2365249..98e839e7 100644 --- a/README.md +++ b/README.md @@ -535,7 +535,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 @@ -627,7 +627,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 ``` @@ -927,7 +927,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 9c84103f..3ba31a49 100644 --- a/UPGRADING.md +++ b/UPGRADING.md @@ -1,5 +1,101 @@ # Ruby SAML Migration Guide +## 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!** @@ -149,7 +245,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" @@ -269,7 +365,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 cefe824b..a2c55cc3 100644 --- a/lib/ruby_saml.rb +++ b/lib/ruby_saml.rb @@ -1,21 +1,40 @@ # frozen_string_literal: true +require 'cgi' +require 'uri' +require 'zlib' +require 'base64' +require 'time' +require 'nokogiri' + require 'ruby_saml/logging' require 'ruby_saml/xml' -require 'ruby_saml/saml_message' +require 'ruby_saml/settings' +require 'ruby_saml/memoizable' + 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/authrequest.rb b/lib/ruby_saml/authrequest.rb index 9c665bd2..40b98fc1 100644 --- a/lib/ruby_saml/authrequest.rb +++ b/lib/ruby_saml/authrequest.rb @@ -6,28 +6,21 @@ require "ruby_saml/setting_error" module RubySaml - # SAML2 Authentication. AuthNRequest (SSO SP initiated, Builder) + # SAML2 Authentication. AuthnRequest (SSO SP initiated) + # + # Shim class that delegates to RubySaml::Sp::Builders::AuthnRequest class Authrequest < SamlMessage - - # AuthNRequest ID + # AuthnRequest ID attr_accessor :uuid alias_method :request_id, :uuid - # Creates the AuthNRequest string. + # 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 + # @return [String] AuthnRequest string that includes the SAMLRequest def create(settings, params = {}) - assign_uuid(settings) - params = create_params(settings, params) - params_prefix = /\?/.match?(settings.idp_sso_service_url) ? '&' : '?' - saml_request = CGI.escape(params.delete("SAMLRequest")) - request_params = +"#{params_prefix}SAMLRequest=#{saml_request}" - params.each_pair do |key, value| - request_params << "{key}=#{CGI.escape(value.to_s)}" - end - raise SettingError.new "Invalid settings, idp_sso_service_url is not set!" if settings.idp_sso_service_url.nil? || settings.idp_sso_service_url.empty? - @login_url = settings.idp_sso_service_url + request_params + create_builder(settings, params) + @login_url = builder.url end # Creates the Get parameters for the request. @@ -35,122 +28,41 @@ def create(settings, params = {}) # @param params [Hash] Some extra parameters to be added in the GET for example the RelayState # @return [Hash] Parameters def create_params(settings, params={}) - # The method expects :RelayState but sometimes we get 'RelayState' instead. - # Based on the HashWithIndifferentAccess value in Rails we could experience - # conflicts so this line will solve them. - binding_redirect = settings.idp_sso_service_binding == Utils::BINDINGS[:redirect] - relay_state = params[:RelayState] || params['RelayState'] - - if relay_state.nil? - params.delete(:RelayState) - params.delete('RelayState') - end + create_builder(settings, params) + is_redirect = settings.idp_sso_service_binding == Utils::BINDINGS[:redirect] - request_doc = create_authentication_xml_doc(settings) + # 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}" - base64_request = RubySaml::XML::Decoder.encode_message(request, compress: binding_redirect) - request_params = {"SAMLRequest" => base64_request} - sp_signing_key = settings.get_sp_signing_key - - if binding_redirect && settings.security[:authn_requests_signed] && sp_signing_key - params['SigAlg'] = settings.get_sp_signature_method - url_string = RubySaml::Utils.build_query( - type: 'SAMLRequest', - data: base64_request, - 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.each_pair do |key, value| - request_params[key] = value.to_s - end - - request_params + # 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) - noko = create_xml_document(settings) - sign_document(noko, settings) + def create_authentication_xml_doc(settings, params = nil) + create_builder(settings, params) + builder.send(:build_xml_document) end - def create_xml_document(settings) - time = Time.now.utc.strftime("%Y-%m-%dT%H:%M:%SZ") - assign_uuid(settings) - 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 - }.compact.reject { |_, v| v.respond_to?(:empty?) && v.empty? } - - builder = Nokogiri::XML::Builder.new do |xml| - xml['samlp'].AuthnRequest(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 Subject element if name_identifier_value_requested is present - 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 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' - - 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 |authn_context_decl_ref| - xml['saml'].AuthnContextDeclRef(authn_context_decl_ref) - end - end - end - end - end - - builder.doc + def sign_document(noko, _settings = nil) + builder.send(:sign_xml_document!, noko) end - def sign_document(noko, settings) - cert, private_key = settings.get_sp_signing_pair - if settings.idp_sso_service_binding == Utils::BINDINGS[:post] && settings.security[:authn_requests_signed] && private_key && cert - RubySaml::XML::DocumentSigner.sign_document!(noko, private_key, cert, settings.get_sp_signature_method, settings.get_sp_digest_method) - else - noko - end - end + private + + attr_reader :builder - def assign_uuid(settings) - @uuid ||= RubySaml::Utils.generate_uuid(settings.sp_uuid_prefix) # rubocop:disable Naming/MemoizedInstanceVariableName + 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/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/logoutrequest.rb b/lib/ruby_saml/logoutrequest.rb index 31bc9c6a..39c25f05 100644 --- a/lib/ruby_saml/logoutrequest.rb +++ b/lib/ruby_saml/logoutrequest.rb @@ -6,9 +6,10 @@ require "ruby_saml/setting_error" module RubySaml - # SAML2 Logout Request (SLO SP initiated, Builder) + # 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 @@ -17,124 +18,55 @@ class Logoutrequest < SamlMessage # @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={}) - assign_uuid(settings) - params = create_params(settings, params) - params_prefix = /\?/.match?(settings.idp_slo_service_url) ? '&' : '?' - saml_request = CGI.escape(params.delete("SAMLRequest")) - request_params = +"#{params_prefix}SAMLRequest=#{saml_request}" - params.each_pair do |key, value| - request_params << "{key}=#{CGI.escape(value.to_s)}" - end - raise SettingError.new "Invalid settings, idp_slo_service_url is not set!" if settings.idp_slo_service_url.nil? || settings.idp_slo_service_url.empty? - @logout_url = settings.idp_slo_service_url + request_params + 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={}) - # The method expects :RelayState but sometimes we get 'RelayState' instead. - # Based on the HashWithIndifferentAccess value in Rails we could experience - # conflicts so this line will solve them. - binding_redirect = settings.idp_slo_service_binding == Utils::BINDINGS[:redirect] - relay_state = params[:RelayState] || params['RelayState'] - - if relay_state.nil? - params.delete(:RelayState) - params.delete('RelayState') - end + def create_params(settings, params = {}) + create_builder(settings, params) - request_doc = create_logout_request_xml_doc(settings) + 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}" - base64_request = RubySaml::XML::Decoder.encode_message(request, compress: binding_redirect) - request_params = {"SAMLRequest" => base64_request} - sp_signing_key = settings.get_sp_signing_key - - if binding_redirect && settings.security[:logout_requests_signed] && sp_signing_key - params['SigAlg'] = settings.get_sp_signature_method - url_string = RubySaml::Utils.build_query( - type: 'SAMLRequest', - data: base64_request, - relay_state: relay_state, - sig_alg: params['SigAlg'] - ) - sign_algorithm = RubySaml::XML.hash_algorithm(settings.get_sp_signature_method) - signature = settings.get_sp_signing_key.sign(sign_algorithm.new, url_string) - params['Signature'] = Base64.strict_encode64(signature) - end - - params.each_pair do |key, value| - request_params[key] = value.to_s - end - - request_params + is_redirect = settings.idp_slo_service_binding == Utils::BINDINGS[:redirect] + 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_logout_request_xml_doc(settings) - noko = create_xml_document(settings) - sign_document(noko, 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) - time = Time.now.utc.strftime('%Y-%m-%dT%H:%M:%SZ') - assign_uuid(settings) - - 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 - }.compact.reject { |_, v| v.respond_to?(:empty?) && v.empty? } - - builder = Nokogiri::XML::Builder.new do |xml| - xml['samlp'].LogoutRequest(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 - nameid_attrs = { - 'NameQualifier' => settings.idp_name_qualifier, - 'SPNameQualifier' => settings.sp_name_qualifier, - 'Format' => settings.name_identifier_format - }.compact.reject { |_, v| v.respond_to?(:empty?) && v.empty? } - xml['saml'].NameID(settings.name_identifier_value, nameid_attrs) - else - # If no NameID is present in the settings we generate one - xml['saml'].NameID(RubySaml::Utils.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 - - builder.doc + create_builder(settings) + builder.send(:build_xml_document) end - def sign_document(noko, settings) - # embed signature - cert, private_key = settings.get_sp_signing_pair - if settings.idp_slo_service_binding == Utils::BINDINGS[:post] && settings.security[:logout_requests_signed] && private_key && cert - RubySaml::XML::DocumentSigner.sign_document!(noko, private_key, cert, settings.get_sp_signature_method, settings.get_sp_digest_method) - else - noko - end + def sign_document(noko, _settings = nil) + builder.send(:sign_xml_document!, noko) end - def assign_uuid(settings) - @uuid ||= RubySaml::Utils.generate_uuid(settings.sp_uuid_prefix) # rubocop:disable Naming/MemoizedInstanceVariableName + 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/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/slo_logoutresponse.rb b/lib/ruby_saml/slo_logoutresponse.rb index 45d3b353..73dbb79e 100644 --- a/lib/ruby_saml/slo_logoutresponse.rb +++ b/lib/ruby_saml/slo_logoutresponse.rb @@ -6,9 +6,10 @@ require "ruby_saml/setting_error" module RubySaml - # SAML2 Logout Response (SLO SP initiated, Parser) + # 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 @@ -21,18 +22,8 @@ class SloLogoutresponse < SamlMessage # @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) - assign_uuid(settings) - params = create_params(settings, request_id, logout_message, params, logout_status_code) - params_prefix = /\?/.match?(settings.idp_slo_service_url) ? '&' : '?' - url = settings.idp_slo_response_service_url || settings.idp_slo_service_url - saml_response = CGI.escape(params.delete("SAMLResponse")) - response_params = +"#{params_prefix}SAMLResponse=#{saml_response}" - params.each_pair do |key, value| - response_params << "{key}=#{CGI.escape(value.to_s)}" - end - - raise SettingError.new "Invalid settings, idp_slo_service_url is not set!" if url.nil? || url.empty? - @logout_url = url + response_params + create_builder(settings, request_id, logout_message, params, logout_status_code) + @logout_url = builder.url end # Creates the Get parameters for the logout response. @@ -43,102 +34,47 @@ def create(settings, request_id = nil, logout_message = nil, params = {}, logout # @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) - # The method expects :RelayState but sometimes we get 'RelayState' instead. - # Based on the HashWithIndifferentAccess value in Rails we could experience - # conflicts so this line will solve them. - binding_redirect = settings.idp_slo_service_binding == Utils::BINDINGS[:redirect] - relay_state = params[:RelayState] || params['RelayState'] - - if relay_state.nil? - params.delete(:RelayState) - params.delete('RelayState') - end + create_builder(settings, request_id, logout_message, params, logout_status_code) + is_redirect = settings.idp_slo_service_binding == Utils::BINDINGS[:redirect] - response_doc = create_logout_response_xml_doc(settings, request_id, logout_message, logout_status_code) + # 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}" - base64_response = RubySaml::XML::Decoder.encode_message(response, compress: binding_redirect) - response_params = { 'SAMLResponse' => base64_response } - sp_signing_key = settings.get_sp_signing_key - - if binding_redirect && settings.security[:logout_responses_signed] && sp_signing_key - params['SigAlg'] = settings.get_sp_signature_method - url_string = RubySaml::Utils.build_query( - type: 'SAMLResponse', - data: base64_response, - 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.each_pair do |key, value| - response_params[key] = value.to_s - end - - response_params + # Get payload parameters + builder.send(:build_payload, is_redirect) end - # Creates the SAMLResponse 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 logout_status_code [String] The StatusCode to be placed as StatusMessage in the logout response - # @return [String] The SAMLResponse String. - def create_logout_response_xml_doc(settings, request_id = nil, logout_message = nil, logout_status_code = nil) - noko = create_xml_document(settings, request_id, logout_message, logout_status_code) - sign_document(noko, settings) + 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) - time = Time.now.utc.strftime('%Y-%m-%dT%H:%M:%SZ') - assign_uuid(settings) - - 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' => settings.idp_slo_response_service_url || settings.idp_slo_service_url - }.compact.reject { |_, v| v.respond_to?(:empty?) && v.empty? } - - # Default values if not provided - status_code ||= 'urn:oasis:names:tc:SAML:2.0:status:Success' - logout_message ||= 'Successfully Signed Out' - - builder = Nokogiri::XML::Builder.new do |xml| - xml['samlp'].LogoutResponse(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(logout_message) - end - end - end - - builder.doc + create_builder(settings, request_id, logout_message, {}, status_code) + builder.send(:build_xml_document) end - def sign_document(noko, settings) - cert, private_key = settings.get_sp_signing_pair - if settings.idp_slo_service_binding == Utils::BINDINGS[:post] && private_key && cert - RubySaml::XML::DocumentSigner.sign_document!(noko, private_key, cert, settings.get_sp_signature_method, settings.get_sp_digest_method) - else - noko - end + def sign_document(noko, _settings = nil) + builder.send(:sign_xml_document!, noko) end - def assign_uuid(settings) - @uuid ||= RubySaml::Utils.generate_uuid(settings.sp_uuid_prefix) # rubocop:disable Naming/MemoizedInstanceVariableName + 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 new file mode 100644 index 00000000..dde6be81 --- /dev/null +++ b/lib/ruby_saml/sp/builders/authn_request.rb @@ -0,0 +1,102 @@ +# frozen_string_literal: true + +module RubySaml + module Sp + module Builders + # 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 + + # 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(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 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) + xml['saml'].SubjectConfirmation(Method: 'urn:oasis:names:tc:SAML:2.0:cm:bearer') + 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' + + 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 |authn_context_decl_ref| + xml['saml'].AuthnContextDeclRef(authn_context_decl_ref) + end + end + end + end + 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 + 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 + + # 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 + 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..5ed48821 --- /dev/null +++ b/lib/ruby_saml/sp/builders/logout_request.rb @@ -0,0 +1,77 @@ +# frozen_string_literal: true + +module RubySaml + module Sp + module Builders + # 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(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 + 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, + 'SPNameQualifier' => settings.sp_name_qualifier, + 'Format' => settings.name_identifier_format + ) + 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..390acfcc --- /dev/null +++ b/lib/ruby_saml/sp/builders/logout_response.rb @@ -0,0 +1,90 @@ +# frozen_string_literal: true + +module RubySaml + module Sp + module Builders + # 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: params) + @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 + + # 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 + # 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) + end + end + 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 + compact_blank!(hash) + 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..ba1dfce2 --- /dev/null +++ b/lib/ruby_saml/sp/builders/message_builder.rb @@ -0,0 +1,187 @@ +# frozen_string_literal: true + +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 + 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) + # @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 + @relay_state = relay_state + @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 + def post_url + service_url + end + memoize_method :post_url + + # Builds the POST request body + # @return [Hash] POST request parameters + def post_body + build_payload(false) + end + memoize_method :post_body + + private + + attr_reader :settings, + :id, + :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(is_redirect) + noko = build_xml_document + 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: 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 is_redirect && sign? && signing_key + payload['SigAlg'] = signature_method + 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 + + 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, + 'xmlns:saml' => RubySaml::XML::NS_ASSERTION, + 'ID' => id, + 'IssueInstant' => utc_timestamp, + 'Version' => '2.0', + 'Destination' => service_url + ) + 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) + cert, private_key = settings.get_sp_signing_pair + return unless cert && private_key + + RubySaml::XML::DocumentSigner.sign_document!( + noko, + private_key, + cert, + signature_method, + digest_method + ) + 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.get_sp_signature_method + end + + # Returns the hash algorithm + # @return [OpenSSL::Digest::Base] 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 + + # 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 + + # 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}") + end + end + end + end + end +end diff --git a/lib/ruby_saml/sp/old/authrequest.rb b/lib/ruby_saml/sp/old/authrequest.rb new file mode 100644 index 00000000..9c665bd2 --- /dev/null +++ b/lib/ruby_saml/sp/old/authrequest.rb @@ -0,0 +1,156 @@ +# 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, Builder) + 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 = {}) + assign_uuid(settings) + params = create_params(settings, params) + params_prefix = /\?/.match?(settings.idp_sso_service_url) ? '&' : '?' + saml_request = CGI.escape(params.delete("SAMLRequest")) + request_params = +"#{params_prefix}SAMLRequest=#{saml_request}" + params.each_pair do |key, value| + request_params << "{key}=#{CGI.escape(value.to_s)}" + end + raise SettingError.new "Invalid settings, idp_sso_service_url is not set!" if settings.idp_sso_service_url.nil? || settings.idp_sso_service_url.empty? + @login_url = settings.idp_sso_service_url + request_params + 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={}) + # The method expects :RelayState but sometimes we get 'RelayState' instead. + # Based on the HashWithIndifferentAccess value in Rails we could experience + # conflicts so this line will solve them. + binding_redirect = settings.idp_sso_service_binding == Utils::BINDINGS[:redirect] + relay_state = params[:RelayState] || params['RelayState'] + + if relay_state.nil? + params.delete(:RelayState) + params.delete('RelayState') + end + + request_doc = create_authentication_xml_doc(settings) + request = request_doc.to_xml(save_with: Nokogiri::XML::Node::SaveOptions::AS_XML) + + Logging.debug "Created AuthnRequest: #{request}" + + base64_request = RubySaml::XML::Decoder.encode_message(request, compress: binding_redirect) + request_params = {"SAMLRequest" => base64_request} + sp_signing_key = settings.get_sp_signing_key + + if binding_redirect && settings.security[:authn_requests_signed] && sp_signing_key + params['SigAlg'] = settings.get_sp_signature_method + url_string = RubySaml::Utils.build_query( + type: 'SAMLRequest', + data: base64_request, + 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.each_pair do |key, value| + request_params[key] = value.to_s + end + + request_params + end + + # Creates the SAMLRequest String. + # @param settings [RubySaml::Settings|nil] Toolkit settings + # @return [String] The SAMLRequest String. + def create_authentication_xml_doc(settings) + noko = create_xml_document(settings) + sign_document(noko, settings) + end + + def create_xml_document(settings) + time = Time.now.utc.strftime("%Y-%m-%dT%H:%M:%SZ") + assign_uuid(settings) + 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 + }.compact.reject { |_, v| v.respond_to?(:empty?) && v.empty? } + + builder = Nokogiri::XML::Builder.new do |xml| + xml['samlp'].AuthnRequest(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 Subject element if name_identifier_value_requested is present + 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 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' + + 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 |authn_context_decl_ref| + xml['saml'].AuthnContextDeclRef(authn_context_decl_ref) + end + end + end + end + end + + builder.doc + end + + def sign_document(noko, settings) + cert, private_key = settings.get_sp_signing_pair + if settings.idp_sso_service_binding == Utils::BINDINGS[:post] && settings.security[:authn_requests_signed] && private_key && cert + RubySaml::XML::DocumentSigner.sign_document!(noko, private_key, cert, settings.get_sp_signature_method, settings.get_sp_digest_method) + else + noko + end + end + + def assign_uuid(settings) + @uuid ||= RubySaml::Utils.generate_uuid(settings.sp_uuid_prefix) # rubocop:disable Naming/MemoizedInstanceVariableName + end + end +end diff --git a/lib/ruby_saml/sp/old/logoutrequest.rb b/lib/ruby_saml/sp/old/logoutrequest.rb new file mode 100644 index 00000000..31bc9c6a --- /dev/null +++ b/lib/ruby_saml/sp/old/logoutrequest.rb @@ -0,0 +1,140 @@ +# 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, Builder) + 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={}) + assign_uuid(settings) + params = create_params(settings, params) + params_prefix = /\?/.match?(settings.idp_slo_service_url) ? '&' : '?' + saml_request = CGI.escape(params.delete("SAMLRequest")) + request_params = +"#{params_prefix}SAMLRequest=#{saml_request}" + params.each_pair do |key, value| + request_params << "{key}=#{CGI.escape(value.to_s)}" + end + raise SettingError.new "Invalid settings, idp_slo_service_url is not set!" if settings.idp_slo_service_url.nil? || settings.idp_slo_service_url.empty? + @logout_url = settings.idp_slo_service_url + request_params + 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={}) + # The method expects :RelayState but sometimes we get 'RelayState' instead. + # Based on the HashWithIndifferentAccess value in Rails we could experience + # conflicts so this line will solve them. + binding_redirect = settings.idp_slo_service_binding == Utils::BINDINGS[:redirect] + relay_state = params[:RelayState] || params['RelayState'] + + if relay_state.nil? + params.delete(:RelayState) + params.delete('RelayState') + end + + request_doc = create_logout_request_xml_doc(settings) + request = request_doc.to_xml(save_with: Nokogiri::XML::Node::SaveOptions::AS_XML) + + Logging.debug "Created SLO Logout Request: #{request}" + + base64_request = RubySaml::XML::Decoder.encode_message(request, compress: binding_redirect) + request_params = {"SAMLRequest" => base64_request} + sp_signing_key = settings.get_sp_signing_key + + if binding_redirect && settings.security[:logout_requests_signed] && sp_signing_key + params['SigAlg'] = settings.get_sp_signature_method + url_string = RubySaml::Utils.build_query( + type: 'SAMLRequest', + data: base64_request, + relay_state: relay_state, + sig_alg: params['SigAlg'] + ) + sign_algorithm = RubySaml::XML.hash_algorithm(settings.get_sp_signature_method) + signature = settings.get_sp_signing_key.sign(sign_algorithm.new, url_string) + params['Signature'] = Base64.strict_encode64(signature) + end + + params.each_pair do |key, value| + request_params[key] = value.to_s + end + + request_params + end + + # Creates the SAMLRequest String. + # @param settings [RubySaml::Settings|nil] Toolkit settings + # @return [String] The SAMLRequest String. + def create_logout_request_xml_doc(settings) + noko = create_xml_document(settings) + sign_document(noko, settings) + end + + def create_xml_document(settings) + time = Time.now.utc.strftime('%Y-%m-%dT%H:%M:%SZ') + assign_uuid(settings) + + 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 + }.compact.reject { |_, v| v.respond_to?(:empty?) && v.empty? } + + builder = Nokogiri::XML::Builder.new do |xml| + xml['samlp'].LogoutRequest(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 + nameid_attrs = { + 'NameQualifier' => settings.idp_name_qualifier, + 'SPNameQualifier' => settings.sp_name_qualifier, + 'Format' => settings.name_identifier_format + }.compact.reject { |_, v| v.respond_to?(:empty?) && v.empty? } + xml['saml'].NameID(settings.name_identifier_value, nameid_attrs) + else + # If no NameID is present in the settings we generate one + xml['saml'].NameID(RubySaml::Utils.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 + + builder.doc + end + + def sign_document(noko, settings) + # embed signature + cert, private_key = settings.get_sp_signing_pair + if settings.idp_slo_service_binding == Utils::BINDINGS[:post] && settings.security[:logout_requests_signed] && private_key && cert + RubySaml::XML::DocumentSigner.sign_document!(noko, private_key, cert, settings.get_sp_signature_method, settings.get_sp_digest_method) + else + noko + end + end + + def assign_uuid(settings) + @uuid ||= RubySaml::Utils.generate_uuid(settings.sp_uuid_prefix) # rubocop:disable Naming/MemoizedInstanceVariableName + end + end +end diff --git a/lib/ruby_saml/sp/old/slo_logoutresponse.rb b/lib/ruby_saml/sp/old/slo_logoutresponse.rb new file mode 100644 index 00000000..45d3b353 --- /dev/null +++ b/lib/ruby_saml/sp/old/slo_logoutresponse.rb @@ -0,0 +1,144 @@ +# 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, Parser) + 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) + assign_uuid(settings) + params = create_params(settings, request_id, logout_message, params, logout_status_code) + params_prefix = /\?/.match?(settings.idp_slo_service_url) ? '&' : '?' + url = settings.idp_slo_response_service_url || settings.idp_slo_service_url + saml_response = CGI.escape(params.delete("SAMLResponse")) + response_params = +"#{params_prefix}SAMLResponse=#{saml_response}" + params.each_pair do |key, value| + response_params << "{key}=#{CGI.escape(value.to_s)}" + end + + raise SettingError.new "Invalid settings, idp_slo_service_url is not set!" if url.nil? || url.empty? + @logout_url = url + response_params + 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) + # The method expects :RelayState but sometimes we get 'RelayState' instead. + # Based on the HashWithIndifferentAccess value in Rails we could experience + # conflicts so this line will solve them. + binding_redirect = settings.idp_slo_service_binding == Utils::BINDINGS[:redirect] + relay_state = params[:RelayState] || params['RelayState'] + + if relay_state.nil? + params.delete(:RelayState) + params.delete('RelayState') + end + + response_doc = create_logout_response_xml_doc(settings, request_id, logout_message, logout_status_code) + response = response_doc.to_xml(save_with: Nokogiri::XML::Node::SaveOptions::AS_XML) + + Logging.debug "Created SLO Logout Response: #{response}" + + base64_response = RubySaml::XML::Decoder.encode_message(response, compress: binding_redirect) + response_params = { 'SAMLResponse' => base64_response } + sp_signing_key = settings.get_sp_signing_key + + if binding_redirect && settings.security[:logout_responses_signed] && sp_signing_key + params['SigAlg'] = settings.get_sp_signature_method + url_string = RubySaml::Utils.build_query( + type: 'SAMLResponse', + data: base64_response, + 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.each_pair do |key, value| + response_params[key] = value.to_s + end + + response_params + end + + # Creates the SAMLResponse 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 logout_status_code [String] The StatusCode to be placed as StatusMessage in the logout response + # @return [String] The SAMLResponse String. + def create_logout_response_xml_doc(settings, request_id = nil, logout_message = nil, logout_status_code = nil) + noko = create_xml_document(settings, request_id, logout_message, logout_status_code) + sign_document(noko, settings) + end + + def create_xml_document(settings, request_id = nil, logout_message = nil, status_code = nil) + time = Time.now.utc.strftime('%Y-%m-%dT%H:%M:%SZ') + assign_uuid(settings) + + 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' => settings.idp_slo_response_service_url || settings.idp_slo_service_url + }.compact.reject { |_, v| v.respond_to?(:empty?) && v.empty? } + + # Default values if not provided + status_code ||= 'urn:oasis:names:tc:SAML:2.0:status:Success' + logout_message ||= 'Successfully Signed Out' + + builder = Nokogiri::XML::Builder.new do |xml| + xml['samlp'].LogoutResponse(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(logout_message) + end + end + end + + builder.doc + end + + def sign_document(noko, settings) + cert, private_key = settings.get_sp_signing_pair + if settings.idp_slo_service_binding == Utils::BINDINGS[:post] && private_key && cert + RubySaml::XML::DocumentSigner.sign_document!(noko, private_key, cert, settings.get_sp_signature_method, settings.get_sp_digest_method) + else + noko + end + end + + def assign_uuid(settings) + @uuid ||= RubySaml::Utils.generate_uuid(settings.sp_uuid_prefix) # rubocop:disable Naming/MemoizedInstanceVariableName + end + end +end diff --git a/lib/ruby_saml/utils.rb b/lib/ruby_saml/utils.rb index 45b2d3bc..2ebeba61 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 @@ -305,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 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) 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(/