diff --git a/README.md b/README.md index 48a8b1aa7..55021d3ea 100644 --- a/README.md +++ b/README.md @@ -49,6 +49,10 @@ def saml_settings # Optional for most SAML IdPs settings.authn_context = "urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport" + # Optional bindings (defaults to Redirect for logout POST for acs) + settings.assertion_consumer_service_binding = "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" + settings.assertion_consumer_logout_service_binding = "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect" + settings end ``` @@ -102,6 +106,19 @@ response.settings = saml_settings response.attributes[:username] ``` +## Request Signing + +XML Dsig request signing is supported. Use the following settings to preform request signing: + +```ruby + settings = OneLogin::RubySaml::Settings.new + settings.sign_request = true + settings.certificate = X509::Certificate.new("CERTIFICATE TEXT") + settings.private_key = X509::PKey::RSA.new("PRIVATE KEY") + + signed_request = request.create(settings) +``` + ## Service Provider Metadata To form a trusted pair relationship with the IdP, the SP (you) need to provide metadata XML @@ -113,12 +130,13 @@ The metdata will be polled by the IdP every few minutes, so updating your settin to the IdP settings. ```ruby -class SamlController < ApplicationController - # ... the rest of your controller definitions ... - def metadata - settings = Account.get_saml_settings - meta = OneLogin::RubySaml::Metadata.new - render :xml => meta.generate(settings) + class SamlController < ApplicationController + # ... the rest of your controller definitions ... + def metadata + settings = Account.get_saml_settings + meta = Onelogin::Saml::Metadata.new + render :xml => meta.generate(settings), :content_type => "application/samlmetadata+xml" + end end end ``` diff --git a/lib/onelogin/ruby-saml/authrequest.rb b/lib/onelogin/ruby-saml/authrequest.rb index affcb23ef..5b5c5e09d 100644 --- a/lib/onelogin/ruby-saml/authrequest.rb +++ b/lib/onelogin/ruby-saml/authrequest.rb @@ -9,7 +9,25 @@ module OneLogin module RubySaml include REXML class Authrequest + + attr_accessor :uuid + + def initialize + @uuid = "_" + UUID.new.generate + end + def create(settings, params = {}) + params = create_params(settings, params) + params_prefix = (settings.idp_sso_target_url =~ /\?/) ? '&' : '?' + saml_request = CGI.escape(params.delete("SAMLRequest")) + request_params = "#{params_prefix}SAMLRequest=#{saml_request}" + params.each_pair do |key, value| + request_params << "&#{key.to_s}=#{CGI.escape(value.to_s)}" + end + settings.idp_sso_target_url + request_params + end + + def create_params(settings, params={}) params = {} if params.nil? request_doc = create_authentication_xml_doc(settings) @@ -22,22 +40,20 @@ def create(settings, params = {}) request = Zlib::Deflate.deflate(request, 9)[2..-5] if settings.compress_request base64_request = Base64.encode64(request) - encoded_request = CGI.escape(base64_request) - params_prefix = (settings.idp_sso_target_url =~ /\?/) ? '&' : '?' - request_params = "#{params_prefix}SAMLRequest=#{encoded_request}" + request_params = {"SAMLRequest" => base64_request} params.each_pair do |key, value| - request_params << "&#{key.to_s}=#{CGI.escape(value.to_s)}" + request_params[key] = value.to_s end - settings.idp_sso_target_url + request_params + request_params end def create_authentication_xml_doc(settings) - uuid = "_" + UUID.new.generate time = Time.now.utc.strftime("%Y-%m-%dT%H:%M:%SZ") # Create AuthnRequest root element using REXML - request_doc = REXML::Document.new + request_doc = XMLSecurity::RequestDocument.new + request_doc.uuid = uuid root = request_doc.add_element "samlp:AuthnRequest", { "xmlns:samlp" => "urn:oasis:names:tc:SAML:2.0:protocol" } root.attributes['ID'] = uuid @@ -56,12 +72,15 @@ def create_authentication_xml_doc(settings) issuer.text = settings.issuer end if settings.name_identifier_format != nil - root.add_element "samlp:NameIDPolicy", { + formats = settings.name_identifier_format.is_a?(Array) ? settings.name_identifier_format : [settings.name_identifier_format] + formats.each do |format| + root.add_element "samlp:NameIDPolicy", { "xmlns:samlp" => "urn:oasis:names:tc:SAML:2.0:protocol", # Might want to make AllowCreate a setting? "AllowCreate" => "true", - "Format" => settings.name_identifier_format - } + "Format" => format + } + end end # BUG fix here -- if an authn_context is defined, add the tags with an "exact" @@ -77,6 +96,11 @@ def create_authentication_xml_doc(settings) } class_ref.text = settings.authn_context end + + if settings.sign_request && settings.private_key && settings.certificate + request_doc.sign_document(settings.private_key, settings.certificate, settings.signature_method, settings.digest_method) + end + request_doc end diff --git a/lib/onelogin/ruby-saml/idp_metadata_parser.rb b/lib/onelogin/ruby-saml/idp_metadata_parser.rb index a87427676..4240d59f2 100644 --- a/lib/onelogin/ruby-saml/idp_metadata_parser.rb +++ b/lib/onelogin/ruby-saml/idp_metadata_parser.rb @@ -20,15 +20,27 @@ def parse(idp_metadata) @document = REXML::Document.new(idp_metadata) OneLogin::RubySaml::Settings.new.tap do |settings| - + settings.entity_id = entity_id settings.idp_sso_target_url = single_signon_service_url + settings.sign_request = sign_request settings.idp_slo_target_url = single_logout_service_url + settings.idp_cert = certificate settings.idp_cert_fingerprint = fingerprint + settings.protocol_binding = single_signon_service_binding end end private + def entity_id + document.root.attributes["entityID"] + end + + def single_signon_service_binding + node = REXML::XPath.first(document, "/md:EntityDescriptor/md:IDPSSODescriptor/md:SingleSignOnService/@Binding", { "md" => METADATA }) + node.value if node + end + def single_signon_service_url node = REXML::XPath.first(document, "/md:EntityDescriptor/md:IDPSSODescriptor/md:SingleSignOnService/@Location", { "md" => METADATA }) node.value if node @@ -42,15 +54,21 @@ def single_logout_service_url def certificate @certificate ||= begin node = REXML::XPath.first(document, "/md:EntityDescriptor/md:IDPSSODescriptor/md:KeyDescriptor[@use='signing']/ds:KeyInfo/ds:X509Data/ds:X509Certificate", { "md" => METADATA, "ds" => DSIG }) - Base64.decode64(node.text) if node + OpenSSL::X509::Certificate.new(Base64.decode64(node.text)) if node + end + end + + def sign_request + @sign_request ||= begin + node = REXML::XPath.first(document, "/md:IDPSSODescriptor", { "md" => METADATA }) + (node.attributes["WantAuthnRequestsSigned"] || false) if node end end def fingerprint @fingerprint ||= begin if certificate - cert = OpenSSL::X509::Certificate.new(certificate) - Digest::SHA1.hexdigest(cert.to_der).upcase.scan(/../).join(":") + Digest::SHA1.hexdigest(certificate.to_der).upcase.scan(/../).join(":") end end end diff --git a/lib/onelogin/ruby-saml/logging.rb b/lib/onelogin/ruby-saml/logging.rb index a6e45ad3f..9e2640d75 100644 --- a/lib/onelogin/ruby-saml/logging.rb +++ b/lib/onelogin/ruby-saml/logging.rb @@ -2,13 +2,16 @@ module OneLogin module RubySaml class Logging + def self.logger=(logger) + @logger = logger + end def self.debug(message) return if !!ENV["ruby-saml/testing"] if defined? Rails Rails.logger.debug message else - puts message + @logger.debug(message) unless @logger.nil? end end @@ -18,7 +21,7 @@ def self.info(message) if defined? Rails Rails.logger.info message else - puts message + @logger.info(message) unless @logger.nil? end end end diff --git a/lib/onelogin/ruby-saml/logoutrequest.rb b/lib/onelogin/ruby-saml/logoutrequest.rb index 8cf9e76ce..814b0de80 100644 --- a/lib/onelogin/ruby-saml/logoutrequest.rb +++ b/lib/onelogin/ruby-saml/logoutrequest.rb @@ -7,40 +7,87 @@ module OneLogin module RubySaml include REXML class Logoutrequest + + ASSERTION = "urn:oasis:names:tc:SAML:2.0:assertion" + PROTOCOL = "urn:oasis:names:tc:SAML:2.0:protocol" + STATUS_SUCCESS = "urn:oasis:names:tc:SAML:2.0:status:Success" - attr_reader :uuid # Can be obtained if neccessary - def initialize - @uuid = "_" + UUID.new.generate + attr_reader :request, :document # Can be obtained if neccessary + attr_accessor :params, :settings + + def initialize(request, doc, settings) + @request = request + @document = doc + @settings = settings end - def create(settings, params={}) + def self.create(settings, params={}) request_doc = create_unauth_xml_doc(settings, params) request = "" request_doc.write(request) + req = new(request, request_doc, settings) + req.params = params + return req + end - deflated_request = Zlib::Deflate.deflate(request, 9)[2..-5] - base64_request = Base64.encode64(deflated_request) - encoded_request = CGI.escape(base64_request) + def encoded_message(compress = true) + encoding_request = request + encoding_request = Zlib::Deflate.deflate(encoding_request, 9)[2..-5] if (settings.compress_request && compress) + msg = Base64.encode64(encoding_request) + msg + end - params_prefix = (settings.idp_slo_target_url =~ /\?/) ? '&' : '?' - request_params = "#{params_prefix}SAMLRequest=#{encoded_request}" + def logout_url + params_prefix = (@settings.idp_slo_target_url =~ /\?/) ? '&' : '?' + request_params = "#{params_prefix}SAMLRequest=#{CGI.escape(encoded_message.gsub("\n",'').gsub("\r",''))}" - params.each_pair do |key, value| + @params.each_pair do |key, value| request_params << "&#{key}=#{CGI.escape(value.to_s)}" end - @logout_url = settings.idp_slo_target_url + request_params + @settings.idp_slo_target_url + request_params + + end + + + + def self.decode(encoded) + Base64.decode64(encoded) + end + + def self.inflate(deflated) + zlib = Zlib::Inflate.new(-Zlib::MAX_WBITS) + zlib.inflate(deflated) + end + + def self.decode_raw_request(request) + if request =~ /^ "urn:oasis:names:tc:SAML:2.0:protocol" } - root.attributes['ID'] = @uuid - root.attributes['IssueInstant'] = time + root.attributes['ID'] = uuid + root.attributes['IssueInstant'] = time root.attributes['Version'] = "2.0" if settings.issuer @@ -62,21 +109,91 @@ def create_unauth_xml_doc(settings, params) sessionindex.text = settings.sessionindex end - # BUG fix here -- if an authn_context is defined, add the tags with an "exact" - # match required for authentication to succeed. If this is not defined, - # the IdP will choose default rules for authentication. (Shibboleth IdP) - if settings.authn_context != nil - requested_context = root.add_element "samlp:RequestedAuthnContext", { - "xmlns:samlp" => "urn:oasis:names:tc:SAML:2.0:protocol", - "Comparison" => "exact", - } - class_ref = requested_context.add_element "saml:AuthnContextClassRef", { - "xmlns:saml" => "urn:oasis:names:tc:SAML:2.0:assertion", - } - class_ref.text = settings.authn_context + + if settings.sign_request && settings.private_key && settings.certificate + request_doc.sign_document(settings.private_key, settings.certificate, settings.signature_method, settings.digest_method) end + request_doc end + + + def create_params(settings, params={}, compress=true) + params = {} if params.nil? + + Logging.debug "Created Logoutrequest: #{request}" + + request_params = {"SAMLRequest" => encoded_message(compress)} + + params.each_pair do |key, value| + request_params[key] = value.to_s + end + + request_params + end + + def issuer + @issuer ||= begin + node = REXML::XPath.first(@document, "/p:LogoutRequest/a:Issuer", { "p" => PROTOCOL, "a" => ASSERTION }) + node ||= REXML::XPath.first(@document, "/p:LogoutRequest/a:Assertion/a:Issuer", { "p" => PROTOCOL, "a" => ASSERTION }) + node.nil? ? nil : node.text + end + end + + + + def uuid + @uuid ||= begin + node = REXML::XPath.first(@document, "/p:LogoutRequest", { "p" => PROTOCOL}) + node.nil? ? nil : node.attributes['ID'] + end + end + + def session_index + @session_index ||= begin + node = REXML::XPath.first(@document, "/p:LogoutRequest/p:SessionIndex", { "p" => PROTOCOL, "a" => ASSERTION}) + node.nil? ? nil : node.text + end + end + + + def name_id + @name_id ||= begin + node = REXML::XPath.first(@document, "/p:LogoutRequest/a:NameID", { "p" => PROTOCOL, "a" => ASSERTION }) + node ||= REXML::XPath.first(@document, "/p:LogoutRequest/a:Assertion/a:NameID", { "p" => PROTOCOL, "a" => ASSERTION }) + node.nil? ? nil : node.text + end + end + + + def attributes + {} + end + + def validate! + validate(false) + end + + def validate(soft = true) + return false unless valid_saml?(soft) + end + + def valid_saml?(soft = true) + Dir.chdir(File.expand_path(File.join(File.dirname(__FILE__), '..', '..', 'schemas'))) do + @schema = Nokogiri::XML::Schema(IO.read('saml20protocol_schema.xsd')) + @xml = Nokogiri::XML(@document.to_s) + end + if soft + @schema.validate(@xml).map{ return false } + else + @schema.validate(@xml).map{ |error| validation_error("#{error.message}\n\n#{@xml.to_s}") } + end + end + + + + + end end end diff --git a/lib/onelogin/ruby-saml/logoutresponse.rb b/lib/onelogin/ruby-saml/logoutresponse.rb index cf9a56d70..3b309a371 100644 --- a/lib/onelogin/ruby-saml/logoutresponse.rb +++ b/lib/onelogin/ruby-saml/logoutresponse.rb @@ -9,6 +9,7 @@ class Logoutresponse ASSERTION = "urn:oasis:names:tc:SAML:2.0:assertion" PROTOCOL = "urn:oasis:names:tc:SAML:2.0:protocol" + STATUS_SUCCESS = "urn:oasis:names:tc:SAML:2.0:status:Success" # For API compability, this is mutable. attr_accessor :settings @@ -25,13 +26,85 @@ class Logoutresponse # It will validate that the logout response matches the ID of the request. # You can also do this yourself through the in_response_to accessor. # - def initialize(response, settings = nil, options = {}) + def initialize(response, document, settings = nil, options = {}) raise ArgumentError.new("Logoutresponse cannot be nil") if response.nil? - self.settings = settings - + @settings = settings @options = options - @response = decode_raw_response(response) - @document = XMLSecurity::SignedDocument.new(response) + @response = response + @document = document + end + + + def self.parse(response, settings = nil, options = {}) + raise ArgumentError.new("Logoutresponse cannot be nil") if response.nil? + resp = decode_raw_response(response) + document = XMLSecurity::SignedDocument.new(resp) + + new(resp, document, settings, options) + end + + + def encoded_message + resp = @response + + deflated_resp = Zlib::Deflate.deflate(resp, 9)[2..-5] + base64_resp = Base64.encode64(deflated_resp) + + return base64_resp + end + + + def logout_url + params_prefix = (@settings.assertion_consumer_service_url =~ /\?/) ? '&' : '?' + request_params = "#{params_prefix}SAMLResponse=#{encoded_message}" + + # TODO support request_params like such + # params.each_pair do |key, value| + # request_params << "&#{key}=#{CGI.escape(value.to_s)}" + # end + @settings.idp_slo_target_url + request_params + end + + def self.create(settings, params={}, status_code=STATUS_SUCCESS, status_message="Logout Successful") + doc = create_unauth_xml_doc(settings, params, status_code,status_message) + resp = "" + doc.write(resp) + + new(resp, doc, settings, params) + end + + def self.create_unauth_xml_doc(settings, params={}, status_code = STATUS_SUCCESS, status_message="Logout Successful") + + time = Time.new().strftime("%Y-%m-%dT%H:%M:%S")+"Z" + + response_doc = XMLSecurity::RequestDocument.new + root = response_doc.add_element "samlp:LogoutResponse", { "xmlns:samlp" => PROTOCOL, "xmlns:saml" => ASSERTION } + uuid = "_" + UUID.new.generate + root.attributes['ID'] = uuid + root.attributes['IssueInstant'] = time + root.attributes['Version'] = "2.0" + + if settings.idp_slo_target_url + root.attributes['Destination'] = settings.idp_slo_target_url + end + + if params.key? :in_response_to + root.attributes['InResponseTo'] = params[:in_response_to] + end + + + if settings.issuer + issuer = root.add_element "saml:Issuer", { "xmlns:saml" => ASSERTION} + issuer.text = settings.issuer + else + fail ArgumentError, "No issuer supplied" + end + + status_el = root.add_element "samlp:Status", { "xmlns:samlp" => PROTOCOL} + status_el.add_element "samlp:StatusCode", { "xmlns:samlp" => PROTOCOL, "Value" => status_code} + status_el.add_element "samlp:Message", { "xmlns:samlp" => PROTOCOL, "Value" => status_message} + + end def validate! @@ -45,7 +118,7 @@ def validate(soft = true) end def success?(soft = true) - unless status_code == "urn:oasis:names:tc:SAML:2.0:status:Success" + unless status_code == STATUS_SUCCESS return soft ? false : validation_error("Bad status code. Expected , but was: <#@status_code> ") end true @@ -75,16 +148,16 @@ def status_code private - def decode(encoded) + def self.decode(encoded) Base64.decode64(encoded) end - def inflate(deflated) + def self.inflate(deflated) zlib = Zlib::Inflate.new(-Zlib::MAX_WBITS) zlib.inflate(deflated) end - def decode_raw_response(response) + def self.decode_raw_response(response) if response =~ /^ "urn:oasis:names:tc:SAML:2.0:protocol", - # Metadata request need not be signed (as we don't publish our cert) - "AuthnRequestsSigned" => false, + "AuthnRequestsSigned" => settings.sign_request, # However we would like assertions signed if idp_cert_fingerprint or idp_cert is set "WantAssertionsSigned" => (!settings.idp_cert_fingerprint.nil? || !settings.idp_cert.nil?) } @@ -27,39 +26,59 @@ def generate(settings) end if settings.assertion_consumer_logout_service_url != nil sp_sso.add_element "md:SingleLogoutService", { - # Add this as a setting to create different bindings? - "Binding" => "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect", + "Binding" => settings.assertion_consumer_logout_service_binding, "Location" => settings.assertion_consumer_logout_service_url, "ResponseLocation" => settings.assertion_consumer_logout_service_url, "isDefault" => true, "index" => 0 } end - if settings.name_identifier_format != nil - name_id = sp_sso.add_element "md:NameIDFormat" - name_id.text = settings.name_identifier_format + if settings.name_identifier_format + formats = settings.name_identifier_format + if !formats.is_a?(Array) + formats = [formats] + end + formats.each do |format| + nf = sp_sso.add_element "md:NameIDFormat" + nf.text = format + end end if settings.assertion_consumer_service_url != nil sp_sso.add_element "md:AssertionConsumerService", { - # Add this as a setting to create different bindings? - "Binding" => "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST", + "Binding" => settings.assertion_consumer_service_binding, "Location" => settings.assertion_consumer_service_url, "isDefault" => true, "index" => 0 } end + + # Add KeyDescriptor if requests are signed + if !settings.certificate.nil? + # Add signing Cert + kd = sp_sso.add_element "md:KeyDescriptor", { "use" => "signing" } + ki = kd.add_element "ds:KeyInfo", {"xmlns:ds" => "http://www.w3.org/2000/09/xmldsig#"} + xd = ki.add_element "ds:X509Data" + xc = xd.add_element "ds:X509Certificate" + xc.text = Base64.encode64(settings.certificate.to_der) + + # Add encryption Cert + kd = sp_sso.add_element "md:KeyDescriptor", { "use" => "encryption" } + ki = kd.add_element "ds:KeyInfo", {"xmlns:ds" => "http://www.w3.org/2000/09/xmldsig#"} + xd = ki.add_element "ds:X509Data" + xc = xd.add_element "ds:X509Certificate" + xc.text = Base64.encode64(settings.certificate.to_der) + end + # With OpenSSO, it might be required to also include # # - meta_doc << REXML::XMLDecl.new + meta_doc << REXML::XMLDecl.new("1.0", "UTF-8", "yes") ret = "" # pretty print the XML so IdP administrators can easily see what the SP supports meta_doc.write(ret, 1) - Logging.debug "Generated metadata:\n#{ret}" - - ret + return ret end end end diff --git a/lib/onelogin/ruby-saml/response.rb b/lib/onelogin/ruby-saml/response.rb index cb1e49ef9..c0cdc875a 100644 --- a/lib/onelogin/ruby-saml/response.rb +++ b/lib/onelogin/ruby-saml/response.rb @@ -23,6 +23,7 @@ def initialize(response, options = {}) @options = options @response = (response =~ /^ PROTOCOL }) + node.attributes["InResponseTo"] + end + end + def sessionindex @sessionindex ||= begin node = xpath_first_from_signed_assertion('/a:AuthnStatement') @@ -117,6 +125,68 @@ def issuer end end + def encrypted? + return !encrypted_key.nil? && !encrypted_value.nil? && !encrypted_algorithm.nil? + end + + def encrypted_key + REXML::XPath.first(document, "//xenc:EncryptedKey//xenc:CipherData/xenc:CipherValue") + end + + def encrypted_value + REXML::XPath.first(document, "//xenc:EncryptedData/xenc:CipherData/xenc:CipherValue") + end + + def encrypted_algorithm + REXML::XPath.first(document, "//xenc:EncryptedData/xenc:EncryptionMethod") + end + + def private_key=(pk) + @private_key = pk + end + + def decrypt_assertion + return @clean_assertion unless @clean_assertion.nil? + + unless @private_key.nil? || !encrypted? + pk = OpenSSL::PKey::RSA.new(@private_key) + data_key = pk.private_decrypt(Base64.decode64(encrypted_key.text), OpenSSL::PKey::RSA::PKCS1_OAEP_PADDING) + + # Derive the encryption alogrithm from the document + algorithm = "aes-256-cbc" # default algorithm + + # In future we need to map values in response with valid ciphers. e.g. aes256-cbc vs aes-256-cbc + if encrypted_algorithm.attributes["Algorithm"].index("aes256").nil? + algorithm = encrypted_algorithm.attributes["Algorithm"] + end + + cipher = OpenSSL::Cipher::Cipher.new(algorithm) + cipher.decrypt + cipher.padding = 0 + cipher.key = data_key + + actual_output = cipher.update(Base64.decode64(encrypted_value.text)) + actual_output << cipher.final + + end_tag = actual_output.index("") + begin_tag = actual_output.index("".length - 1)) + end + end + + return @clean_assertion + end + + def response + if encrypted? && !@private_key.nil? + @response = decrypt_assertion + end + + return @response + end + private def validation_error(message) @@ -160,6 +230,12 @@ def validate_response_state(soft = true) end def xpath_first_from_signed_assertion(subelt=nil) + unless @clean_assertion.nil? + doc = XMLSecurity::SignedDocument.new(@clean_assertion) + node = REXML::XPath.first(doc, "//saml:NameID") + return node + end + node = REXML::XPath.first(document, "/p:Response/a:Assertion[@ID='#{document.signed_element_id}']#{subelt}", { "p" => PROTOCOL, "a" => ASSERTION }) node ||= REXML::XPath.first(document, "/p:Response[@ID='#{document.signed_element_id}']/a:Assertion#{subelt}", { "p" => PROTOCOL, "a" => ASSERTION }) node diff --git a/lib/onelogin/ruby-saml/settings.rb b/lib/onelogin/ruby-saml/settings.rb index d3f2a87ef..9e86be1cd 100644 --- a/lib/onelogin/ruby-saml/settings.rb +++ b/lib/onelogin/ruby-saml/settings.rb @@ -9,20 +9,32 @@ def initialize(overrides = {}) end end attr_accessor :assertion_consumer_service_url, :issuer, :sp_name_qualifier + attr_accessor :assertion_consumer_service_binding attr_accessor :idp_sso_target_url, :idp_cert_fingerprint, :idp_cert, :name_identifier_format + attr_accessor :entity_id attr_accessor :authn_context attr_accessor :idp_slo_target_url attr_accessor :name_identifier_value attr_accessor :sessionindex attr_accessor :assertion_consumer_logout_service_url + attr_accessor :assertion_consumer_logout_service_binding attr_accessor :compress_request attr_accessor :double_quote_xml_attribute_values attr_accessor :passive attr_accessor :protocol_binding + attr_accessor :sign_request, :certificate, :private_key, :digest_method, :signature_method private - DEFAULTS = {:compress_request => true, :double_quote_xml_attribute_values => false} + DEFAULTS = { + :assertion_consumer_service_binding => "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST", + :assertion_consumer_logout_service_binding => "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect", + :compress_request => true, + :sign_request => false, + :double_quote_xml_attribute_values => false, + :digest_method => "SHA1", + :signature_method => "SHA1" + } end end end diff --git a/lib/onelogin/ruby-saml/version.rb b/lib/onelogin/ruby-saml/version.rb index 452692bfb..75abc8115 100644 --- a/lib/onelogin/ruby-saml/version.rb +++ b/lib/onelogin/ruby-saml/version.rb @@ -1,5 +1,5 @@ module OneLogin module RubySaml - VERSION = '0.8.1' + VERSION = '0.9.0' end end diff --git a/lib/xml_security.rb b/lib/xml_security.rb index de1530cae..be738ba01 100644 --- a/lib/xml_security.rb +++ b/lib/xml_security.rb @@ -33,10 +33,130 @@ module XMLSecurity - class SignedDocument < REXML::Document + class BaseDocument < REXML::Document + C14N = "http://www.w3.org/2001/10/xml-exc-c14n#" DSIG = "http://www.w3.org/2000/09/xmldsig#" + def initialize(source = nil) + super(source) + self << REXML::XMLDecl.new("1.0", "UTF-8") + end + + def canon_algorithm(element) + algorithm = element + if algorithm.is_a?(REXML::Element) + algorithm = element.attribute('Algorithm').value + end + + case algorithm + when "http://www.w3.org/2001/10/xml-exc-c14n#" then Nokogiri::XML::XML_C14N_EXCLUSIVE_1_0 + when "http://www.w3.org/TR/2001/REC-xml-c14n-20010315" then Nokogiri::XML::XML_C14N_1_0 + when "http://www.w3.org/2006/12/xml-c14n11" then Nokogiri::XML::XML_C14N_1_1 + else Nokogiri::XML::XML_C14N_EXCLUSIVE_1_0 + end + end + + def algorithm(element) + algorithm = element + if algorithm.is_a?(REXML::Element) + algorithm = element.attribute("Algorithm").value + algorithm = algorithm && algorithm =~ /sha(.*?)$/i && $1.to_i + end + + case algorithm + when 256 then OpenSSL::Digest::SHA256 + when 384 then OpenSSL::Digest::SHA384 + when 512 then OpenSSL::Digest::SHA512 + else + OpenSSL::Digest::SHA1 + end + end + + end + + class RequestDocument < BaseDocument + + SHA1 = "http://www.w3.org/2000/09/xmldsig#sha1" + SHA256 = "http://www.w3.org/2000/09/xmldsig#sha256" + SHA384 = "http://www.w3.org/2000/09/xmldsig#sha384" + SHA512 = "http://www.w3.org/2000/09/xmldsig#sha512" + ENVELOPED_SIG = "http://www.w3.org/2000/09/xmldsig#enveloped-signature" + INC_PREFIX_LIST = "#default samlp saml ds xs xsi" + + attr_accessor :uuid + + # + # + # + # + # + # + # + # + # + # etc. + # + # + # + # + # + def sign_document(private_key, certificate, signature_method = SHA1, digest_method = SHA1) + + noko = Nokogiri.parse(self.to_s) + canon_doc = noko.canonicalize(canon_algorithm(C14N)) + + signature_element = REXML::Element.new("Signature").add_namespace(DSIG) + signed_info_element = signature_element.add_element("SignedInfo") + signed_info_element.add_element("CanonicalizationMethod", {"Algorithm" => C14N}) + signed_info_element.add_element("SignatureMethod", {"Algorithm"=>SHA1}) + + # Add Reference + reference_element = signed_info_element.add_element("Reference", {"URI" => "##{uuid}"}) + digest_method_element = reference_element.add_element("DigestMethod", {"Algorithm" => digest_method}) + reference_element.add_element("DigestValue").text = compute_digest(canon_doc, algorithm(digest_method_element)) + + # Add Transforms + transforms_element = reference_element.add_element("Transforms") + transforms_element.add_element("Transform", {"Algorithm" => ENVELOPED_SIG}) + transforms_element.add_element("Transform", {"Algorithm" => C14N}) + transforms_element.add_element("InclusiveNamespaces", {"xmlns" => C14N, "PrefixList" => INC_PREFIX_LIST}) + + # add SignatureValue + noko_sig_element = Nokogiri.parse(signature_element.to_s) + noko_signed_info_element = noko_sig_element.at_xpath('//ds:SignedInfo', 'ds' => DSIG) + canon_string = noko_signed_info_element.canonicalize(canon_algorithm(C14N)) + signature = compute_signature(private_key, algorithm(signature_method).new, canon_string) + signature_element.add_element("SignatureValue").text = signature + + # add KeyInfo + key_info_element = signature_element.add_element("KeyInfo") + x509_element = key_info_element.add_element("X509Data") + x509_cert_element = x509_element.add_element("X509Certificate") + if certificate.is_a?(String) + certificate = OpenSSL::X509::Certificate.new(certificate) + end + x509_cert_element.text = Base64.encode64(certificate.to_der).gsub(/\n/, "") + + # add the signature + self.root.add_element(signature_element) + end + + protected + + def compute_signature(private_key, signature_algorithm, document) + Base64.encode64(private_key.sign(signature_algorithm, document)).gsub(/\n/, "") + end + + def compute_digest(document, digest_algorithm) + digest = digest_algorithm.digest(document) + Base64.encode64(digest).strip! + end + + end + + class SignedDocument < BaseDocument + attr_accessor :signed_element_id def initialize(response) @@ -79,7 +199,6 @@ def validate_signature(base64_cert, soft = true) element.remove end - # verify signature signed_info_element = REXML::XPath.first(@sig_element, "//ds:SignedInfo", {"ds"=>DSIG}) noko_sig_element = document.at_xpath('//ds:Signature', 'ds' => DSIG) @@ -134,28 +253,6 @@ def extract_signed_element_id self.signed_element_id = reference_element.attribute("URI").value[1..-1] unless reference_element.nil? end - def canon_algorithm(element) - algorithm = element.attribute('Algorithm').value if element - case algorithm - when "http://www.w3.org/2001/10/xml-exc-c14n#" then Nokogiri::XML::XML_C14N_EXCLUSIVE_1_0 - when "http://www.w3.org/TR/2001/REC-xml-c14n-20010315" then Nokogiri::XML::XML_C14N_1_0 - when "http://www.w3.org/2006/12/xml-c14n11" then Nokogiri::XML::XML_C14N_1_1 - else Nokogiri::XML::XML_C14N_EXCLUSIVE_1_0 - end - end - - def algorithm(element) - algorithm = element.attribute("Algorithm").value if element - algorithm = algorithm && algorithm =~ /sha(.*?)$/i && $1.to_i - case algorithm - when 256 then OpenSSL::Digest::SHA256 - when 384 then OpenSSL::Digest::SHA384 - when 512 then OpenSSL::Digest::SHA512 - else - OpenSSL::Digest::SHA1 - end - end - def extract_inclusive_namespaces if element = REXML::XPath.first(self, "//ec:InclusiveNamespaces", { "ec" => C14N }) prefix_list = element.attributes.get_attribute("PrefixList").value diff --git a/test/certificates/ruby-saml.crt b/test/certificates/ruby-saml.crt new file mode 100644 index 000000000..60a1291d4 --- /dev/null +++ b/test/certificates/ruby-saml.crt @@ -0,0 +1,14 @@ +-----BEGIN CERTIFICATE----- +MIICGzCCAYQCCQCNNcQXom32VDANBgkqhkiG9w0BAQUFADBSMQswCQYDVQQGEwJV +UzELMAkGA1UECBMCSU4xFTATBgNVBAcTDEluZGlhbmFwb2xpczERMA8GA1UEChMI +T25lTG9naW4xDDAKBgNVBAsTA0VuZzAeFw0xNDA0MjMxODQxMDFaFw0xNTA0MjMx +ODQxMDFaMFIxCzAJBgNVBAYTAlVTMQswCQYDVQQIEwJJTjEVMBMGA1UEBxMMSW5k +aWFuYXBvbGlzMREwDwYDVQQKEwhPbmVMb2dpbjEMMAoGA1UECxMDRW5nMIGfMA0G +CSqGSIb3DQEBAQUAA4GNADCBiQKBgQDo6m+QZvYQ/xL0ElLgupK1QDcYL4f5Pckw +sNgS9pUvV7fzTqCHk8ThLxTk42MQ2McJsOeUJVP728KhymjFCqxgP4VuwRk9rpAl +0+mhy6MPdyjyA6G14jrDWS65ysLchK4t/vwpEDz0SQlEoG1kMzllSm7zZS3XregA +7DjNaUYQqwIDAQABMA0GCSqGSIb3DQEBBQUAA4GBALM2vGCiQ/vm+a6v40+VX2zd +qHA2Q/1vF1ibQzJ54MJCOVWvs+vQXfZFhdm0OPM2IrDU7oqvKPqP6xOAeJK6H0yP +7M4YL3fatSvIYmmfyXC9kt3Svz/NyrHzPhUnJ0ye/sUSXxnzQxwcm/9PwAqrQaA3 +QpQkH57ybF/OoryPe+2h +-----END CERTIFICATE----- diff --git a/test/certificates/ruby-saml.key b/test/certificates/ruby-saml.key new file mode 100644 index 000000000..902d3b0f8 --- /dev/null +++ b/test/certificates/ruby-saml.key @@ -0,0 +1,15 @@ +-----BEGIN RSA PRIVATE KEY----- +MIICXAIBAAKBgQDo6m+QZvYQ/xL0ElLgupK1QDcYL4f5PckwsNgS9pUvV7fzTqCH +k8ThLxTk42MQ2McJsOeUJVP728KhymjFCqxgP4VuwRk9rpAl0+mhy6MPdyjyA6G1 +4jrDWS65ysLchK4t/vwpEDz0SQlEoG1kMzllSm7zZS3XregA7DjNaUYQqwIDAQAB +AoGBALGR6bRBit+yV5TUU3MZSrf8WQSLWDLgs/33FQSAEYSib4+DJke2lKbI6jkG +UoSJgFUXFbaQLtMY2+3VDsMKPBdAge9gIdvbkC4yoKjLGm/FBDOxxZcfLpR+9OPq +U3qM9D0CNuliBWI7Je+p/zs09HIYucpDXy9E18KA1KNF6rfhAkEA9KoNam6wAKnm +vMzz31ws3RuIOUeo2rx6aaVY95+P9tTxd6U+pNkwxy1aCGP+InVSwlYNA1aQ4Axi +/GdMIWMkxwJBAPO1CP7cQNZQmu7yusY+GUObDII5YK9WLaY4RAicn5378crPBFxv +Ukqf9G6FHo7u88iTCIp+vwa3Hn9Tumg3iP0CQQDgUXWBasCVqzCxU5wY4tMDWjXY +hpoLCpmVeRML3dDJt004rFm2HKe7Rhpw7PTZNQZOxUSjFeA4e0LaNf838UWLAkB8 +QfbHM3ffjhOg96PhhjINdVWoZCb230LBOHj/xxPfUmFTHcBEfQIBSJMxcrBFAnLL +9qPpMXymqOFk3ETz9DTlAj8E0qGbp78aVbTOtuwEwNJII+RPw+Zkc+lKR+yaWkAz +fIXw527NPHH3+rnBG72wyZr9ud4LAum9jh+5No1LQpk= +-----END RSA PRIVATE KEY----- diff --git a/test/idp_metadata_parser_test.rb b/test/idp_metadata_parser_test.rb index daab729b3..430406cd5 100644 --- a/test/idp_metadata_parser_test.rb +++ b/test/idp_metadata_parser_test.rb @@ -9,8 +9,10 @@ class IdpMetadataParserTest < Test::Unit::TestCase settings = idp_metadata_parser.parse(idp_metadata) assert_equal "https://example.hello.com/access/saml/login", settings.idp_sso_target_url + assert_equal "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect", settings.protocol_binding assert_equal "F1:3C:6B:80:90:5A:03:0E:6C:91:3E:5D:15:FA:DD:B0:16:45:48:72", settings.idp_cert_fingerprint assert_equal "https://example.hello.com/access/saml/logout", settings.idp_slo_target_url + assert_equal "https://example.hello.com/access/saml/idp.xml", settings.entity_id end end diff --git a/test/logoutrequest_test.rb b/test/logoutrequest_test.rb index 9e419919d..9c1b979ae 100644 --- a/test/logoutrequest_test.rb +++ b/test/logoutrequest_test.rb @@ -9,20 +9,21 @@ class RequestTest < Test::Unit::TestCase settings.idp_slo_target_url = "http://unauth.com/logout" settings.name_identifier_value = "f00f00" - unauth_url = OneLogin::RubySaml::Logoutrequest.new.create(settings) + unauth_url = OneLogin::RubySaml::Logoutrequest.create(settings).logout_url assert unauth_url =~ /^http:\/\/unauth\.com\/logout\?SAMLRequest=/ inflated = decode_saml_request_payload(unauth_url) - assert_match /^/, inflated) + assert_match(/ nil }) + unauth_url = OneLogin::RubySaml::Logoutrequest.create(settings, { :hello => nil }).logout_url assert unauth_url =~ /&hello=$/ - unauth_url = OneLogin::RubySaml::Logoutrequest.new.create(settings, { :foo => "bar" }) + unauth_url = OneLogin::RubySaml::Logoutrequest.create(settings, { :foo => "bar" }).logout_url assert unauth_url =~ /&foo=bar$/ end @@ -31,10 +32,10 @@ class RequestTest < Test::Unit::TestCase sessionidx = UUID.new.generate settings.sessionindex = sessionidx - unauth_url = OneLogin::RubySaml::Logoutrequest.new.create(settings, { :name_id => "there" }) + unauth_url = OneLogin::RubySaml::Logoutrequest.create(settings, { :name_id => "there" }).logout_url inflated = decode_saml_request_payload(unauth_url) - assert_match /), inflated end @@ -45,10 +46,10 @@ class RequestTest < Test::Unit::TestCase name_identifier_value = "abc123" settings.name_identifier_value = name_identifier_value - unauth_url = OneLogin::RubySaml::Logoutrequest.new.create(settings, { :name_id => "there" }) + unauth_url = OneLogin::RubySaml::Logoutrequest.create(settings, { :name_id => "there" }).logout_url inflated = decode_saml_request_payload(unauth_url) - assert_match /), inflated end @@ -57,7 +58,7 @@ class RequestTest < Test::Unit::TestCase settings.idp_slo_target_url = "http://example.com/" settings.name_identifier_format = nil - assert_raises(OneLogin::RubySaml::ValidationError) { OneLogin::RubySaml::Logoutrequest.new.create(settings) } + assert_raises(OneLogin::RubySaml::ValidationError) { OneLogin::RubySaml::Logoutrequest.create(settings).logout_url } end context "when the target url doesn't contain a query string" do @@ -66,7 +67,7 @@ class RequestTest < Test::Unit::TestCase settings.idp_slo_target_url = "http://example.com/" settings.name_identifier_value = "f00f00" - unauth_url = OneLogin::RubySaml::Logoutrequest.new.create(settings) + unauth_url = OneLogin::RubySaml::Logoutrequest.create(settings).logout_url assert unauth_url =~ /^http:\/\/example.com\?SAMLRequest/ end end @@ -77,7 +78,7 @@ class RequestTest < Test::Unit::TestCase settings.idp_slo_target_url = "http://example.com/?field=value" settings.name_identifier_value = "f00f00" - unauth_url = OneLogin::RubySaml::Logoutrequest.new.create(settings) + unauth_url = OneLogin::RubySaml::Logoutrequest.create(settings).logout_url assert unauth_url =~ /^http:\/\/example.com\?field=value&SAMLRequest/ end end @@ -88,13 +89,34 @@ class RequestTest < Test::Unit::TestCase settings.idp_slo_target_url = "http://example.com/?field=value" settings.name_identifier_value = "f00f00" - unauth_req = OneLogin::RubySaml::Logoutrequest.new - unauth_url = unauth_req.create(settings) + unauth_req = OneLogin::RubySaml::Logoutrequest.create(settings) + unauth_url = unauth_req.logout_url inflated = decode_saml_request_payload(unauth_url) + + + assert_match %r[ID='#{unauth_req.uuid}'], inflated end end + + context "when the settings indicate to sign the request" do + should "created a signed request" do + settings = OneLogin::RubySaml::Settings.new + settings.idp_slo_target_url = "http://example.com/?field=value" + settings.name_identifier_value = "f00f00" + # sign the request + settings.sign_request = true + settings.certificate = ruby_saml_cert + settings.private_key = ruby_saml_key + + unauth_req = OneLogin::RubySaml::Logoutrequest.create(settings) + unauth_url = unauth_req.logout_url + + inflated = decode_saml_request_payload(unauth_url) + assert_match %r[([a-zA-Z0-9/+=]+)], inflated + end + end end def decode_saml_request_payload(unauth_url) @@ -102,6 +124,7 @@ def decode_saml_request_payload(unauth_url) decoded = Base64.decode64(payload) zstream = Zlib::Inflate.new(-Zlib::MAX_WBITS) + inflated = zstream.inflate(decoded) zstream.finish zstream.close diff --git a/test/logoutresponse_test.rb b/test/logoutresponse_test.rb index 73995fcc7..ab321ded1 100644 --- a/test/logoutresponse_test.rb +++ b/test/logoutresponse_test.rb @@ -9,20 +9,20 @@ class RubySamlTest < Test::Unit::TestCase assert_raises(ArgumentError) { OneLogin::RubySaml::Logoutresponse.new(nil) } end should "default to empty settings" do - logoutresponse = OneLogin::RubySaml::Logoutresponse.new( valid_response) + logoutresponse = OneLogin::RubySaml::Logoutresponse.parse( valid_response) assert logoutresponse.settings.nil? end should "accept constructor-injected settings" do - logoutresponse = OneLogin::RubySaml::Logoutresponse.new(valid_response, settings) + logoutresponse = OneLogin::RubySaml::Logoutresponse.parse(valid_response, settings) assert !logoutresponse.settings.nil? end should "accept constructor-injected options" do - logoutresponse = OneLogin::RubySaml::Logoutresponse.new(valid_response, nil, { :foo => :bar} ) + logoutresponse = OneLogin::RubySaml::Logoutresponse.parse(valid_response, nil, { :foo => :bar} ) assert !logoutresponse.options.empty? end should "support base64 encoded responses" do expected_response = valid_response - logoutresponse = OneLogin::RubySaml::Logoutresponse.new(Base64.encode64(expected_response), settings) + logoutresponse = OneLogin::RubySaml::Logoutresponse.parse(Base64.encode64(expected_response), settings) assert_equal expected_response, logoutresponse.response end @@ -32,7 +32,7 @@ class RubySamlTest < Test::Unit::TestCase should "validate the response" do in_relation_to_request_id = random_id - logoutresponse = OneLogin::RubySaml::Logoutresponse.new(valid_response({:uuid => in_relation_to_request_id}), settings) + logoutresponse = OneLogin::RubySaml::Logoutresponse.parse(valid_response({:uuid => in_relation_to_request_id}), settings) assert logoutresponse.validate @@ -47,14 +47,14 @@ class RubySamlTest < Test::Unit::TestCase expected_request_id = "_some_other_expected_uuid" opts = { :matches_request_id => expected_request_id} - logoutresponse = OneLogin::RubySaml::Logoutresponse.new(valid_response, settings, opts) + logoutresponse = OneLogin::RubySaml::Logoutresponse.parse(valid_response, settings, opts) assert !logoutresponse.validate assert_not_equal expected_request_id, logoutresponse.in_response_to end should "invalidate responses with wrong request status" do - logoutresponse = OneLogin::RubySaml::Logoutresponse.new(unsuccessful_response, settings) + logoutresponse = OneLogin::RubySaml::Logoutresponse.parse(unsuccessful_response, settings) assert !logoutresponse.validate assert !logoutresponse.success? @@ -65,7 +65,7 @@ class RubySamlTest < Test::Unit::TestCase should "validates good responses" do in_relation_to_request_id = random_id - logoutresponse = OneLogin::RubySaml::Logoutresponse.new(valid_response({:uuid => in_relation_to_request_id}), settings) + logoutresponse = OneLogin::RubySaml::Logoutresponse.parse(valid_response({:uuid => in_relation_to_request_id}), settings) logoutresponse.validate! end @@ -75,32 +75,32 @@ class RubySamlTest < Test::Unit::TestCase expected_request_id = "_some_other_expected_id" opts = { :matches_request_id => expected_request_id} - logoutresponse = OneLogin::RubySaml::Logoutresponse.new(valid_response, settings, opts) + logoutresponse = OneLogin::RubySaml::Logoutresponse.parse(valid_response, settings, opts) assert_raises(OneLogin::RubySaml::ValidationError) { logoutresponse.validate! } end should "raise validation error for wrong request status" do - logoutresponse = OneLogin::RubySaml::Logoutresponse.new(unsuccessful_response, settings) + logoutresponse = OneLogin::RubySaml::Logoutresponse.parse(unsuccessful_response, settings) assert_raises(OneLogin::RubySaml::ValidationError) { logoutresponse.validate! } end should "raise validation error when in bad state" do # no settings - logoutresponse = OneLogin::RubySaml::Logoutresponse.new(unsuccessful_response) + logoutresponse = OneLogin::RubySaml::Logoutresponse.parse(unsuccessful_response) assert_raises(OneLogin::RubySaml::ValidationError) { logoutresponse.validate! } end should "raise validation error when in lack of issuer setting" do bad_settings = settings bad_settings.issuer = nil - logoutresponse = OneLogin::RubySaml::Logoutresponse.new(unsuccessful_response, bad_settings) + logoutresponse = OneLogin::RubySaml::Logoutresponse.parse(unsuccessful_response, bad_settings) assert_raises(OneLogin::RubySaml::ValidationError) { logoutresponse.validate! } end should "raise error for invalid xml" do - logoutresponse = OneLogin::RubySaml::Logoutresponse.new(invalid_xml_response, settings) + logoutresponse = OneLogin::RubySaml::Logoutresponse.parse(invalid_xml_response, settings) assert_raises(OneLogin::RubySaml::ValidationError) { logoutresponse.validate! } end diff --git a/test/metadata_test.rb b/test/metadata_test.rb new file mode 100644 index 000000000..c01006a7c --- /dev/null +++ b/test/metadata_test.rb @@ -0,0 +1,54 @@ +require File.expand_path(File.join(File.dirname(__FILE__), "test_helper")) + +class MetadataTest < Test::Unit::TestCase + + def setup + @settings = OneLogin::RubySaml::Settings.new + @settings.issuer = "https://example.com/" + @settings.name_identifier_format = "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress" + @settings.assertion_consumer_service_url = "https://foo.example/saml/consume" + end + + should "should generate Service Provider Metadata" do + xml_text = OneLogin::RubySaml::Metadata.new.generate(@settings) + + # assert correct xml declaration + start = "\n "urn:oasis:names:tc:SAML:2.0:metadata", + "ds" => "http://www.w3.org/2000/09/xmldsig#" + }) + cert_text = cert_node.text + cert = OpenSSL::X509::Certificate.new(Base64.decode64(cert_text)) + assert_equal ruby_saml_cert.to_der, cert.to_der + end + + should "generate Service Provider Metadata with a list of supported NameIdFormats" do + @settings.name_identifier_format = [ + "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress", + "urn:oasis:names:tc:SAML:1.1:nameid-format:persistant", + ] + xml_text = OneLogin::RubySaml::Metadata.new.generate(@settings) + + # assert xml_text can be parsed into an xml doc + xml_doc = REXML::Document.new(xml_text) + + name_id_nodes = REXML::XPath.match(xml_doc, "//md:NameIDFormat") + assert_equal 2, name_id_nodes.length, "2 NameIDFormat nodes found" + end + +end diff --git a/test/request_test.rb b/test/request_test.rb index 8728a3962..0b1b2c465 100644 --- a/test/request_test.rb +++ b/test/request_test.rb @@ -16,7 +16,8 @@ class RequestTest < Test::Unit::TestCase zstream.finish zstream.close - assert_match /^/, inflated + assert_match //, inflated assert_match //, decoded + assert_match //, inflated assert_match /([a-zA-Z0-9/+=]+)], request_xml + end + + end end end diff --git a/test/test_helper.rb b/test/test_helper.rb index 9b940f81b..46c32525e 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -76,4 +76,24 @@ def idp_metadata @idp_metadata ||= File.read(File.join(File.dirname(__FILE__), 'responses', 'idp_descriptor.xml')) end + def ruby_saml_cert + @ruby_saml_cert ||= OpenSSL::X509::Certificate.new(ruby_saml_cert_text) + end + + def ruby_saml_cert_fingerprint + @ruby_saml_cert_fingerprint ||= Digest::SHA1.hexdigest(ruby_saml_cert.to_der).scan(/../).join(":") + end + + def ruby_saml_cert_text + File.read(File.join(File.dirname(__FILE__), 'certificates', 'ruby-saml.crt')) + end + + def ruby_saml_key + @ruby_saml_key ||= OpenSSL::PKey::RSA.new(ruby_saml_key_text) + end + + def ruby_saml_key_text + File.read(File.join(File.dirname(__FILE__), 'certificates', 'ruby-saml.key')) + end + end diff --git a/test/xml_security_test.rb b/test/xml_security_test.rb index 2ecea9f71..1dca21e7d 100644 --- a/test/xml_security_test.rb +++ b/test/xml_security_test.rb @@ -128,6 +128,24 @@ class XmlSecurityTest < Test::Unit::TestCase end end + context "XMLSecurity::DSIG" do + should "sign an xml document" do + settings = OneLogin::RubySaml::Settings.new({ + :idp_sso_target_url => "https://idp.example.com/sso", + :protocol_binding => "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST", + :issuer => "https://sp.exmapl.com/saml2", + :assertion_consumer_service_url => "https://sp.example.com/acs" + }) + + request = OneLogin::RubySaml::Authrequest.new.create_authentication_xml_doc(settings) + request.sign_document(ruby_saml_key, ruby_saml_cert) + + # verify our signature + signed_doc = XMLSecurity::SignedDocument.new(request.to_s) + signed_doc.validate_document(ruby_saml_cert_fingerprint, false) + end + end + context "StarfieldTMS" do setup do @response = OneLogin::RubySaml::Response.new(fixture(:starfield_response))