saml-idp / saml_idp

Ruby SAML Identity Provider, best used with Rails (though not required)
MIT License
263 stars 181 forks source link

Tried building with gem but failed, started writing a custom builder #175

Closed jhblacklock closed 2 years ago

jhblacklock commented 2 years ago

I've used your gem in another project with success!! Thanks so much. However, I have run into snags with my current client so attempted to deconstruct the gem and write my own builder. The client is using a componentspace and the error messages are not terribly useful. I assume it's something to do with my signature value but was hoping to get another set of eyes. Something is up with the order in which I'm encoding the xml.

# frozen_string_literal: true

# SHA1 the canonical version of the Assertion.
# Generate a SignedInfo XML fragment with the SHA1 signature
# Sign the SignedInfo XML fragment, again the canonical form
# Take the SignedInfo, the Signature and the key info and create a Signature XML fragment
# Insert this SignatureXML into the Assertion (should go right before the saml:subject)
# Now take the assertion(with the signature included) and insert it into the Response
# SHA1 this response
# Generate a SignedInfo XML fragment with the SHA1 signature
# Sign the SignedInfo XML fragment, again the canonical form
# Take the SignedInfo, the Signature and the key info and create a Signature XML fragment
# Insert this SignatureXML into the Response
# Add the XML version info to the response.

module Client
  class LoginService
    def issuer_url
      'localhost:3000'
    end

    def destination_url
      "#{Settings.entity.base_url}/api/rest/v2/authentication/saml"
    end

    def cert
      'config/idp/test.crt'
    end

    def secret_key
      'config/idp/secret_test.pem'
    end

    def name_id
      'jhblacklock@gmail.com'
    end

    def cert
      OpenSSL::X509::Certificate.new(File.read(cert)).to_s
        .gsub(/-----BEGIN CERTIFICATE-----/, '')
        .gsub(/-----END CERTIFICATE-----/, '')
        .delete("\n").strip
    end

    def iso
      yield.iso8601
    end

    def now
      @now ||= Time.now.utc
    end

    def not_on_or_after
      iso { now + 3 * 60 }
    end

    def not_before
      iso { now - 5 }
    end

    def now_iso
      iso { now }
    end

    def digest_method
      'http://www.w3.org/2000/09/xmldsig#sha1'
    end

    def digest_algorithm
      OpenSSL::Digest::SHA1.new
    end

    def signature(raw)
      encoded(raw).delete("\n")
    end

    def encoded(raw)
      key = OpenSSL::PKey::RSA.new(File.open(secret_key), '')
      Base64.strict_encode64(key.sign(OpenSSL::Digest::SHA1.new, raw))
    end

    def raw_file
      builder(signed: false).delete("\n")
    end

    def raw_assertion
      assertion_builder(signed: false).delete("\n")
    end

    def signed
      builder(signed: true).delete("\n")
    end

    def digest_value(xml_raw)
      digest(xml_raw).strip
    end

    def digest(xml_raw)
      noko_raw = Nokogiri::XML::Document.parse(xml_raw)
      inclusive_namespaces = []
      canon_algorithm = Nokogiri::XML::XML_C14N_EXCLUSIVE_1_0
      canon_hashed_element = noko_raw.canonicalize(canon_algorithm, inclusive_namespaces)
      hash = digest_algorithm.digest(canon_hashed_element)
      Base64.strict_encode64(hash).delete("\n")
    end

    def encode_signed_message(signed)
      Base64.strict_encode64(signed)
    end

    def reference_id
      @reference_string ||= SecureRandom.uuid
    end

    def reference_string
      @reference_string ||= "id#{reference_id}"
    end

    def response_id
      @response_id ||= SecureRandom.uuid
    end

    def response_id_string
      @response_id_string ||= "id#{response_id}"
    end

    def assertion_builder(signed: false)
      <<~XML
        <saml2:Assertion
          ID="#{reference_string}"
          IssueInstant="#{now_iso}"
          Version="2.0"
          xmlns:saml2="urn:oasis:names:tc:SAML:2.0:assertion"
        >
          <saml2:Issuer
            Format="urn:oasis:names:tc:SAML:2.0:nameid-format:entity"
            xmlns:saml2="urn:oasis:names:tc:SAML:2.0:assertion">#{issuer_url}</saml2:Issuer>
          #{signature_builder(raw_assertion, reference_string) if signed}
          <saml2:Subject xmlns:saml2="urn:oasis:names:tc:SAML:2.0:assertion">
          <saml2:NameID Format="urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified">#{name_id}</saml2:NameID>
            <saml2:SubjectConfirmation Method="urn:oasis:names:tc:SAML:2.0:cm:bearer">
              <saml2:SubjectConfirmationData NotOnOrAfter="#{not_on_or_after}" Recipient="#{destination_url}" />
            </saml2:SubjectConfirmation>
          </saml2:Subject>
          <saml2:Conditions NotBefore="#{not_before}" NotOnOrAfter="#{not_on_or_after}" xmlns:saml2="urn:oasis:names:tc:SAML:2.0:assertion">
          <saml2:AudienceRestriction>
            <saml2:Audience>#{destination_url}</saml2:Audience>
          </saml2:AudienceRestriction>
          </saml2:Conditions>
          <saml2:AuthnStatement
            AuthnInstant="#{now_iso}"
            SessionIndex="#{reference_string}"
            xmlns:saml2="urn:oasis:names:tc:SAML:2.0:assertion"
          >
            <saml2:AuthnContext>
              <saml2:AuthnContextClassRef>urn:oasis:names:tc:SAML:2.0:ac:classes:unspecified</saml2:AuthnContextClassRef>
            </saml2:AuthnContext>
          </saml2:AuthnStatement>
        </saml2:Assertion>
      XML
    end

    def signed_info_builder(raw, id_string)
      <<~XML
        <ds:SignedInfo>
          <ds:CanonicalizationMethod Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#" />
          <ds:SignatureMethod Algorithm="http://www.w3.org/2000/09/xmldsig#rsa-sha1" />
          <ds:Reference URI="#{id_string}">
            <ds:Transforms>
              <ds:Transform Algorithm="http://www.w3.org/2000/09/xmldsig#enveloped-signature" />
              <ds:Transform Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#" />
            </ds:Transforms>
            <ds:DigestMethod Algorithm="#{digest_method}" />
            <ds:DigestValue>#{digest_value(raw)}</ds:DigestValue>
          </ds:Reference>
        </ds:SignedInfo>
      XML
    end

    def signature_builder(raw, id_string)
      <<~XML
      <ds:Signature xmlns:ds="http://www.w3.org/2000/09/xmldsig#">
        #{signed_info_builder(raw, id_string)}
        #{signature_value_builder(raw, id_string)}
        <ds:KeyInfo>
          <ds:X509Data>#{certificate_builder}</ds:X509Data>
        </ds:KeyInfo>
        </ds:Signature>
      XML
    end

    def certificate_builder
      <<~XML
        <ds:X509Certificate>#{cert}</ds:X509Certificate>
      XML
    end

    def signature_value_builder(raw, _id_string)
      <<~XML
        <ds:SignatureValue>#{signature(raw).strip}</ds:SignatureValue>
      XML
    end

    def build
      signed.delete("\n")
    end

    def status_builder
      <<~XML
        <saml2p:Status xmlns:saml2p="urn:oasis:names:tc:SAML:2.0:protocol">
          <saml2p:StatusCode Value="urn:oasis:names:tc:SAML:2.0:status:Success"/>
        </saml2p:Status>
      XML
    end

    def builder(signed: false)
      resp_options = {}
      resp_options[:Destination] = destination_url
      resp_options[:ID] = response_id_string
      resp_options[:IssueInstant] = now_iso
      resp_options[:Version] =  '2.0'
      resp_options['xmlns:saml2p'] = 'urn:oasis:names:tc:SAML:2.0:protocol'

      builder = Builder::XmlMarkup.new
      builder.tag! 'saml2p:Response', resp_options do |response|
        response << issuer_builder
        response << signature_builder(raw_file, response_id_string) if signed
        response << status_builder
        response << assertion_builder(signed: true)
      end
    end

    def issuer_builder
      <<~XML
        <saml2:Issuer
          Format="urn:oasis:names:tc:SAML:2.0:nameid-format:entity"
          xmlns:saml2="urn:oasis:names:tc:SAML:2.0:assertion"
        >#{issuer_url}</saml2:Issuer>
      XML
    end
  end
end
Zogoo commented 2 years ago

@jhblacklock can you provide with us error message or stack trace.

jhblacklock commented 2 years ago

Zogoo, thanks for the reply. I had an issue with the SP cert which caused the failure. Works great now!