diff --git a/README.md b/README.md index 9b65258..5cd86c1 100644 --- a/README.md +++ b/README.md @@ -99,7 +99,7 @@ end ### Generating a SAML Response -The gem provides a `SamlResponse` class used to generate a custom signed unencrypted XML SAML response. The SAML response is currently generated by the `ruby-saml-idp` gem and that functionality will be replaced with this class in a later update. +The gem provides a `SamlResponse` class used to generate a custom signed XML SAML response with an assertion that can be encrypted by setting `encryption_enabled` to `true`. **Usage** @@ -109,7 +109,6 @@ saml_response = FakeIdp::SamlResponse.new( saml_acs_url: "http://localhost.dev:3000/auth/saml/devidp/callback", saml_request_id: "_#{SecureRandom.uuid}", name_id: "bob_builder@gmail.com", - audience_uri: "http://localhost.dev:3000", issuer_uri: "http://publichost.dev:3000", algorithm_name: :sha256, certificate: "YOUR IDP CERTIFICATE HERE", @@ -124,8 +123,6 @@ saml_response = FakeIdp::SamlResponse.new( }, ) -# Returns a signed unencrypted XML SAML response document +# Returns a signed XML SAML response document saml_response.build ``` - -**Note**: Encrypted assertions will be supported in a future update. \ No newline at end of file diff --git a/lib/fake_idp/application.rb b/lib/fake_idp/application.rb index 42ea8d1..f2f283b 100644 --- a/lib/fake_idp/application.rb +++ b/lib/fake_idp/application.rb @@ -1,18 +1,16 @@ +# frozen_string_literal: true + +require_relative "./saml_response" +require "ruby-saml" + module FakeIdp class Application < Sinatra::Base include SamlIdp::Controller - get '/saml/auth' do + get "/saml/auth" do begin - decode_SAMLRequest(mock_saml_request) - @saml_acs_url = callback_url - - configure_cert_and_keys - - @saml_response = encode_SAMLResponse( - name_id, - attributes_provider: attributes_statement(user_attrs), - ) + decode_SAMLRequest(generate_saml_request) + @saml_response = Base64.encode64(build_xml_saml_response).delete("\r\n") erb :auth rescue => e @@ -26,60 +24,44 @@ def configuration FakeIdp.configuration end - def callback_url - configuration.callback_url - end - - def configure_cert_and_keys - self.x509_certificate = idp_certificate - self.secret_key = configuration.idp_secret_key - self.algorithm = configuration.algorithm - end - - def certificate - Base64.encode64(configuration.certificate).delete("\n") - end - - def idp_certificate - Base64.encode64(configuration.idp_certificate).delete("\n") - end - - def user_attrs - signed_in_user_attrs.merge(configuration.additional_attributes) + def build_xml_saml_response + FakeIdp::SamlResponse.new( + name_id: configuration.name_id, + issuer_uri: configuration.issuer, + saml_acs_url: @saml_acs_url, # Defined in #decode_SAMLRequest in ruby-saml-idp gem + saml_request_id: @saml_request_id, # Defined in #decode_SAMLRequest in ruby-saml-idp gem + user_attributes: user_attributes, + algorithm_name: configuration.algorithm, + certificate: configuration.idp_certificate, + secret_key: configuration.idp_secret_key, + encryption_enabled: configuration.encryption_enabled, + ).build end - def signed_in_user_attrs + def user_attributes { uuid: configuration.sso_uid, username: configuration.username, first_name: configuration.first_name, last_name: configuration.last_name, - email: configuration.email - } - end - - def name_id - configuration.name_id + email: configuration.email, + }.merge(configuration.additional_attributes) end - def mock_saml_request - current_gem_dir = File.dirname(__FILE__) - sample_file_name = "#{current_gem_dir}/sample_init_request.txt" - File.write(sample_file_name, params[:SAMLRequest]) if params[:SAMLRequest] - File.read(sample_file_name).strip + # An AuthRequest is required by the ruby-saml-idp gem to begin the process of returning + # a SAMLResponse. We will likely remove the ruby-saml-idp dependency in a future update + def generate_saml_request + auth_request = OneLogin::RubySaml::Authrequest.new + auth_url = auth_request.create(saml_settings) + CGI.unescape(auth_url.split("=").last) end - def attributes_statement(attributes) - attributes_xml = attributes_xml(attributes).join - - %[#{attributes_xml}] - end - - def attributes_xml(attributes) - attributes.map do |name, value| - attribute_value = %[#{value}] - - %[#{attribute_value}] + def saml_settings + OneLogin::RubySaml::Settings.new.tap do |setting| + setting.assertion_consumer_service_url = configuration.callback_url + setting.issuer = configuration.issuer + setting.idp_sso_target_url = configuration.idp_sso_target_url + setting.name_identifier_format = FakeIdp::SamlResponse::EMAIL_ADDRESS_FORMAT end end end diff --git a/lib/fake_idp/configuration.rb b/lib/fake_idp/configuration.rb index 470219b..33b9e9c 100644 --- a/lib/fake_idp/configuration.rb +++ b/lib/fake_idp/configuration.rb @@ -11,6 +11,8 @@ class Configuration :certificate, :idp_certificate, :idp_secret_key, + :idp_sso_target_url, + :issuer, :algorithm, :additional_attributes, :encryption_enabled, @@ -27,6 +29,8 @@ def initialize @certificate = default_certificate @idp_certificate = default_idp_certificate @idp_secret_key = default_idp_secret_key + @idp_sso_target_url = idp_sso_target_url + @issuer = issuer @algorithm = default_algorithm @additional_attributes = {} @encryption_enabled = default_encryption diff --git a/lib/fake_idp/encryptor.rb b/lib/fake_idp/encryptor.rb index 962fbf7..8d9a7d8 100644 --- a/lib/fake_idp/encryptor.rb +++ b/lib/fake_idp/encryptor.rb @@ -1,4 +1,5 @@ require "xmlenc" +require "builder" module FakeIdp class Encryptor @@ -35,7 +36,7 @@ def encrypt def openssl_cert @_openssl_cert ||= if certificate.is_a?(String) - OpenSSL::X509::Certificate.new(Base64.decode64(certificate)) + OpenSSL::X509::Certificate.new(certificate) else certificate end diff --git a/lib/fake_idp/saml_response.rb b/lib/fake_idp/saml_response.rb index a76fd94..05ec5eb 100644 --- a/lib/fake_idp/saml_response.rb +++ b/lib/fake_idp/saml_response.rb @@ -3,12 +3,13 @@ require "securerandom" require "nokogiri" require "openssl" +require_relative "./encryptor" module FakeIdp class SamlResponse DSIG = "http://www.w3.org/2000/09/xmldsig#" SAML_VERSION = "2.0" - ISSUER_VALUE = "urn:oasis:names:tc:SAML:2.0:assertion" + ASSERTION_NAMESPACE = "urn:oasis:names:tc:SAML:2.0:assertion" ENTITY_FORMAT = "urn:oasis:names:SAML:2.0:nameid-format:entity" BEARER_FORMAT = "urn:oasis:names:tc:SAML:2.0:cm:bearer" ENVELOPE_SCHEMA = "http://www.w3.org/2000/09/xmldsig#enveloped-signature" @@ -23,7 +24,6 @@ class SamlResponse def initialize( name_id:, - audience_uri:, issuer_uri:, saml_acs_url:, saml_request_id:, @@ -31,10 +31,9 @@ def initialize( algorithm_name:, certificate:, secret_key:, - encryption_enabled: + encryption_enabled: false ) @name_id = name_id - @audience_uri = audience_uri @issuer_uri = issuer_uri @saml_acs_url = saml_acs_url @saml_request_id = saml_request_id @@ -55,11 +54,33 @@ def build end document_with_digest = replace_digest_value(@builder.to_xml) - replace_signature_value(document_with_digest) + document = replace_signature_value(document_with_digest) + encrypt_assertion!(document) end private + def encrypt_assertion!(document) + return document unless @encryption_enabled + + document_copy = document.dup + working_document = Nokogiri::XML(document) + assertion = working_document.at_xpath("//saml:Assertion", "saml" => ASSERTION_NAMESPACE) + encrypted_assertion_xml = FakeIdp::Encryptor.new( + assertion.to_xml, + @certificate, + ).encrypt + + document_copy = Nokogiri::XML(document_copy) + target_assertion_node = document_copy.at_xpath( + "//saml:Assertion", + "saml" => ASSERTION_NAMESPACE, + ) + # Replace Assertion node with encrypted assertion + target_assertion_node.replace(encrypted_assertion_xml) + document_copy.to_xml + end + def replace_digest_value(document) document_copy = document.dup working_document = Nokogiri::XML(document) @@ -76,7 +97,7 @@ def replace_digest_value(document) # Replace digest node with the generated value document_copy = Nokogiri::XML(document_copy) - target_digest_node = document_copy.at_xpath("//ds:DigestValue") + target_digest_node = document_copy.at_xpath("//ds:DigestValue", "ds" => DSIG) target_digest_node.content = digest_value document_copy end @@ -91,13 +112,13 @@ def replace_signature_value(document) signature_value = sign(canon_string) - target_signature_node = document_copy.at_xpath("//ds:SignatureValue") + target_signature_node = document_copy.at_xpath("//ds:SignatureValue", "ds" => DSIG) target_signature_node.content = signature_value document_copy.to_xml end def build_issuer_segment(parent_attribute) - parent_attribute[:saml].Issuer("xmlns:saml" => ISSUER_VALUE) do |issuer| + parent_attribute[:saml].Issuer("xmlns:saml" => ASSERTION_NAMESPACE) do |issuer| issuer << @issuer_uri end end @@ -166,7 +187,7 @@ def build_assertion_signature(parent_attribute) # The digest_value is set and derived from creating a digest of the Assertion element # without the signature element after the document is generated - reference[:ds].DigestValue { |d| d << "" } + reference[:ds].DigestValue("xmlns:ds" => DSIG) { |d| d << "" } end end @@ -174,7 +195,7 @@ def build_assertion_signature(parent_attribute) # document is generated signature[:ds].SignatureValue { |signature_value| signature_value << "" } - signature.KeyInfo("xmlns" => DSIG) do |key_info| + signature.KeyInfo("xmlns:ds" => DSIG) do |key_info| key_info[:ds].X509Data do |x509_data| x509_data[:ds].X509Certificate do |x509_certificate| x509_certificate << Base64.encode64(@certificate) @@ -222,13 +243,12 @@ def root_namespace_attributes "InResponseTo" => @saml_request_id, "IssueInstant" => @timestamp.strftime("%Y-%m-%dT%H:%M:%S"), "Version" => SAML_VERSION, - "xmlns:ds" => DSIG, } end def assertion_namespace_attributes { - "xmlns:saml" => ISSUER_VALUE, + "xmlns:saml" => ASSERTION_NAMESPACE, "ID" => assertion_reference_response_id, "IssueInstant" => @timestamp.strftime("%Y-%m-%dT%H:%M:%S"), "Version" => SAML_VERSION, diff --git a/lib/fake_idp/sample_init_request.txt b/lib/fake_idp/sample_init_request.txt deleted file mode 100644 index 47b8894..0000000 --- a/lib/fake_idp/sample_init_request.txt +++ /dev/null @@ -1 +0,0 @@ -fZFLa8MwEIT/im86OZb8iJPFNpiEQiAtJX0ceimKrBCDLLlauY9/X9mmkELa6/LNzO5sgbxTPdSDO+uDfBskuqBGlNa1Rm+MxqGT9kHa91bIp8O+JGfneogiZQRXZ4MOEkppNLpEYsZJsPUureajxRVBmi3zWcB9Kgl225K8Jnmc5uuUhixNs5CyJAmPa7EMl/S0Ys1RiDWlHkUc5E6j49qVJKbMo3kYp48sA7aCLH4hwbO0OAXHC6/47JRGGNNKMlgNhmOLoHknEZyAh/p2Dx4E/nPzpaT/X9Nb44wwilTFSMO0na2uN1REl0wx137nPXfbe6Na8RXUSpmPjZXcyZI4O/gib4ztuPt7C7Zg06RtwtOEgux4q+qmsRKRRNWc+vu/1Tc= diff --git a/spec/encryptor_spec.rb b/spec/encryptor_spec.rb index ec38168..c4dd386 100644 --- a/spec/encryptor_spec.rb +++ b/spec/encryptor_spec.rb @@ -5,7 +5,7 @@ describe FakeIdp::Encryptor do it "encrypts and decrypts XML" do raw_xml = "bar" - encryptor = described_class.new(raw_xml, Base64.encode64(fake_certificate).delete("\n")) + encryptor = described_class.new(raw_xml, fake_certificate) encrypted_xml = encryptor.encrypt expect(encrypted_xml).to_not match raw_xml diff --git a/spec/saml_response_spec.rb b/spec/saml_response_spec.rb index 6dffff2..697d8ba 100644 --- a/spec/saml_response_spec.rb +++ b/spec/saml_response_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require_relative "spec_helper" require_relative "../lib/fake_idp/saml_response" require "ruby-saml" @@ -12,7 +14,7 @@ config.name_id = "bobthessouser@example.com" config.first_name = "Reid" config.last_name = "Smith" - configuration.email = "reid@msn.com" + config.email = "reid@msn.com" config.idp_certificate = fake_public_certificate config.idp_secret_key = fake_private_key config.algorithm = :sha1 @@ -20,37 +22,71 @@ end let(:configuration) { FakeIdp.configuration } - - it "generates a valid SAML response" do - settings = OneLogin::RubySaml::Settings.new( - allowed_clock_drift: 10000000, + let(:settings) do + OneLogin::RubySaml::Settings.new( + allowed_clock_drift: 10_000_000, assertion_consumer_service_url: configuration.callback_url, idp_cert: configuration.idp_certificate, + private_key: fake_private_key, ) + end - saml_response = FakeIdp::SamlResponse.new( - saml_acs_url: configuration.callback_url, - saml_request_id: "_#{SecureRandom.uuid}", - name_id: configuration.name_id, - audience_uri: "http://localhost.dev:3000", - issuer_uri: "http://publichost.dev:3000", - algorithm_name: configuration.algorithm, - certificate: configuration.idp_certificate, - secret_key: configuration.idp_secret_key, - encryption_enabled: false, - user_attributes: { - uuid: configuration.sso_uid, - username: configuration.username, - first_name: configuration.first_name, - last_name: configuration.last_name, - email: configuration.email, - }, - ).build + context "encrypted assertion" do + it "generates a valid SAML response" do + saml_response = FakeIdp::SamlResponse.new( + saml_acs_url: configuration.callback_url, + saml_request_id: "_#{SecureRandom.uuid}", + name_id: configuration.name_id, + issuer_uri: "http://publichost.dev:3000", + algorithm_name: configuration.algorithm, + certificate: configuration.idp_certificate, + secret_key: configuration.idp_secret_key, + encryption_enabled: true, + user_attributes: { + uuid: configuration.sso_uid, + username: configuration.username, + first_name: configuration.first_name, + last_name: configuration.last_name, + email: configuration.email, + }, + ).build - response = OneLogin::RubySaml::Response.new(saml_response, settings: settings) - response.is_valid? + response = OneLogin::RubySaml::Response.new(saml_response, settings: settings) + response.is_valid? - expect(response.errors).to be_empty + expect(response.errors).to be_empty + expect(response.decrypted_document.to_s).to be_present + expect(response.document.to_s).to be_present + end + end + + context "unencrypted assertion" do + it "generates a valid SAML response without a decrypted document value" do + saml_response = FakeIdp::SamlResponse.new( + saml_acs_url: configuration.callback_url, + saml_request_id: "_#{SecureRandom.uuid}", + name_id: configuration.name_id, + issuer_uri: "http://publichost.dev:3000", + algorithm_name: configuration.algorithm, + certificate: configuration.idp_certificate, + secret_key: configuration.idp_secret_key, + encryption_enabled: false, + user_attributes: { + uuid: configuration.sso_uid, + username: configuration.username, + first_name: configuration.first_name, + last_name: configuration.last_name, + email: configuration.email, + }, + ).build + + response = OneLogin::RubySaml::Response.new(saml_response, settings: settings) + response.is_valid? + + expect(response.errors).to be_empty + expect(response.decrypted_document.to_s).to be_blank + expect(response.document.to_s).to be_present + end end def fake_public_certificate