require "base64" require "net/http" require "net/https" require "rexml/document" require "rexml/xpath" # Only supports SAML 2.0 module OneLogin module RubySaml include REXML # Auxiliary class to retrieve and parse the Identity Provider Metadata # # This class does not validate in any way the URL that is introduced, # make sure to validate it properly before use it in a parse_remote method. # Read the `Security warning` section of the README.md file to get more info # class IdpMetadataParser module SamlMetadata module Vocabulary METADATA = "urn:oasis:names:tc:SAML:2.0:metadata".freeze DSIG = "http://www.w3.org/2000/09/xmldsig#".freeze NAME_FORMAT = "urn:oasis:names:tc:SAML:2.0:attrname-format:*".freeze SAML_ASSERTION = "urn:oasis:names:tc:SAML:2.0:assertion".freeze end NAMESPACE = { "md" => Vocabulary::METADATA, "NameFormat" => Vocabulary::NAME_FORMAT, "saml" => Vocabulary::SAML_ASSERTION, "ds" => Vocabulary::DSIG }.freeze end include SamlMetadata::Vocabulary attr_reader :document attr_reader :response attr_reader :options # fetch IdP descriptors from a metadata document def self.get_idps(metadata_document, only_entity_id=nil) path = "//md:EntityDescriptor#{only_entity_id && '[@entityID="' + only_entity_id + '"]'}/md:IDPSSODescriptor" REXML::XPath.match( metadata_document, path, SamlMetadata::NAMESPACE ) end # Parse the Identity Provider metadata and update the settings with the # IdP values # # @param url [String] Url where the XML of the Identity Provider Metadata is published. # @param validate_cert [Boolean] If true and the URL is HTTPs, the cert of the domain is checked. # # @param options [Hash] options used for parsing the metadata and the returned Settings instance # @option options [OneLogin::RubySaml::Settings, Hash] :settings the OneLogin::RubySaml::Settings object which gets the parsed metadata merged into or an hash for Settings overrides. # @option options [String, nil] :entity_id when this is given, the entity descriptor for this ID is used. When omitted, the first entity descriptor is used. # @option options [String, Array, nil] :sso_binding an ordered list of bindings to detect the single signon URL. The first binding in the list that is included in the metadata will be used. # @option options [String, Array, nil] :slo_binding an ordered list of bindings to detect the single logout URL. The first binding in the list that is included in the metadata will be used. # @option options [String, Array, nil] :name_id_format an ordered list of NameIDFormats to detect a desired value. The first NameIDFormat in the list that is included in the metadata will be used. # # @return [OneLogin::RubySaml::Settings] # # @raise [HttpError] Failure to fetch remote IdP metadata def parse_remote(url, validate_cert = true, options = {}) idp_metadata = get_idp_metadata(url, validate_cert) parse(idp_metadata, options) end # Parse the Identity Provider metadata and return the results as Hash # # @param url [String] Url where the XML of the Identity Provider Metadata is published. # @param validate_cert [Boolean] If true and the URL is HTTPs, the cert of the domain is checked. # # @param options [Hash] options used for parsing the metadata # @option options [String, nil] :entity_id when this is given, the entity descriptor for this ID is used. When omitted, the first entity descriptor is used. # @option options [String, Array, nil] :sso_binding an ordered list of bindings to detect the single signon URL. The first binding in the list that is included in the metadata will be used. # @option options [String, Array, nil] :slo_binding an ordered list of bindings to detect the single logout URL. The first binding in the list that is included in the metadata will be used. # @option options [String, Array, nil] :name_id_format an ordered list of NameIDFormats to detect a desired value. The first NameIDFormat in the list that is included in the metadata will be used. # # @return [Hash] # # @raise [HttpError] Failure to fetch remote IdP metadata def parse_remote_to_hash(url, validate_cert = true, options = {}) parse_remote_to_array(url, validate_cert, options)[0] end # Parse all Identity Provider metadata and return the results as Array # # @param url [String] Url where the XML of the Identity Provider Metadata is published. # @param validate_cert [Boolean] If true and the URL is HTTPs, the cert of the domain is checked. # # @param options [Hash] options used for parsing the metadata # @option options [String, nil] :entity_id when this is given, the entity descriptor for this ID is used. When omitted, all found IdPs are returned. # @option options [String, Array, nil] :sso_binding an ordered list of bindings to detect the single signon URL. The first binding in the list that is included in the metadata will be used. # @option options [String, Array, nil] :slo_binding an ordered list of bindings to detect the single logout URL. The first binding in the list that is included in the metadata will be used. # @option options [String, Array, nil] :name_id_format an ordered list of NameIDFormats to detect a desired value. The first NameIDFormat in the list that is included in the metadata will be used. # # @return [Array] # # @raise [HttpError] Failure to fetch remote IdP metadata def parse_remote_to_array(url, validate_cert = true, options = {}) idp_metadata = get_idp_metadata(url, validate_cert) parse_to_array(idp_metadata, options) end # Parse the Identity Provider metadata and update the settings with the IdP values # # @param idp_metadata [String] # # @param options [Hash] :settings to provide the OneLogin::RubySaml::Settings object or an hash for Settings overrides # @option options [OneLogin::RubySaml::Settings, Hash] :settings the OneLogin::RubySaml::Settings object which gets the parsed metadata merged into or an hash for Settings overrides. # @option options [String, nil] :entity_id when this is given, the entity descriptor for this ID is used. When omitted, the first entity descriptor is used. # @option options [String, Array, nil] :sso_binding an ordered list of bindings to detect the single signon URL. The first binding in the list that is included in the metadata will be used. # @option options [String, Array, nil] :slo_binding an ordered list of bindings to detect the single logout URL. The first binding in the list that is included in the metadata will be used. # @option options [String, Array, nil] :name_id_format an ordered list of NameIDFormats to detect a desired value. The first NameIDFormat in the list that is included in the metadata will be used. # # @return [OneLogin::RubySaml::Settings] def parse(idp_metadata, options = {}) parsed_metadata = parse_to_hash(idp_metadata, options) unless parsed_metadata[:cache_duration].nil? cache_valid_until_timestamp = OneLogin::RubySaml::Utils.parse_duration(parsed_metadata[:cache_duration]) unless cache_valid_until_timestamp.nil? if parsed_metadata[:valid_until].nil? || cache_valid_until_timestamp < Time.parse(parsed_metadata[:valid_until], Time.now.utc).to_i parsed_metadata[:valid_until] = Time.at(cache_valid_until_timestamp).utc.strftime("%Y-%m-%dT%H:%M:%SZ") end end end # Remove the cache_duration because on the settings # we only gonna suppot valid_until parsed_metadata.delete(:cache_duration) settings = options[:settings] if settings.nil? OneLogin::RubySaml::Settings.new(parsed_metadata) elsif settings.is_a?(Hash) OneLogin::RubySaml::Settings.new(settings.merge(parsed_metadata)) else merge_parsed_metadata_into(settings, parsed_metadata) end end # Parse the Identity Provider metadata and return the results as Hash # # @param idp_metadata [String] # # @param options [Hash] options used for parsing the metadata and the returned Settings instance # @option options [String, nil] :entity_id when this is given, the entity descriptor for this ID is used. When omitted, the first entity descriptor is used. # @option options [String, Array, nil] :sso_binding an ordered list of bindings to detect the single signon URL. The first binding in the list that is included in the metadata will be used. # @option options [String, Array, nil] :slo_binding an ordered list of bindings to detect the single logout URL. The first binding in the list that is included in the metadata will be used. # @option options [String, Array, nil] :name_id_format an ordered list of NameIDFormats to detect a desired value. The first NameIDFormat in the list that is included in the metadata will be used. # # @return [Hash] def parse_to_hash(idp_metadata, options = {}) parse_to_array(idp_metadata, options)[0] end # Parse all Identity Provider metadata and return the results as Array # # @param idp_metadata [String] # # @param options [Hash] options used for parsing the metadata and the returned Settings instance # @option options [String, nil] :entity_id when this is given, the entity descriptor for this ID is used. When omitted, all found IdPs are returned. # @option options [String, Array, nil] :sso_binding an ordered list of bindings to detect the single signon URL. The first binding in the list that is included in the metadata will be used. # @option options [String, Array, nil] :slo_binding an ordered list of bindings to detect the single logout URL. The first binding in the list that is included in the metadata will be used. # @option options [String, Array, nil] :name_id_format an ordered list of NameIDFormats to detect a desired value. The first NameIDFormat in the list that is included in the metadata will be used. # # @return [Array] def parse_to_array(idp_metadata, options = {}) parse_to_idp_metadata_array(idp_metadata, options).map { |idp_md| idp_md.to_hash(options) } end def parse_to_idp_metadata_array(idp_metadata, options = {}) @document = REXML::Document.new(idp_metadata) @options = options idpsso_descriptors = self.class.get_idps(@document, options[:entity_id]) if !idpsso_descriptors.any? raise ArgumentError.new("idp_metadata must contain an IDPSSODescriptor element") end idpsso_descriptors.map {|id| IdpMetadata.new(id, id.parent.attributes["entityID"])} end private # Retrieve the remote IdP metadata from the URL or a cached copy. # @param url [String] Url where the XML of the Identity Provider Metadata is published. # @param validate_cert [Boolean] If true and the URL is HTTPs, the cert of the domain is checked. # @return [REXML::document] Parsed XML IdP metadata # @raise [HttpError] Failure to fetch remote IdP metadata def get_idp_metadata(url, validate_cert) uri = URI.parse(url) raise ArgumentError.new("url must begin with http or https") unless /^https?/ =~ uri.scheme http = Net::HTTP.new(uri.host, uri.port) if uri.scheme == "https" http.use_ssl = true # Most IdPs will probably use self signed certs http.verify_mode = validate_cert ? OpenSSL::SSL::VERIFY_PEER : OpenSSL::SSL::VERIFY_NONE # Net::HTTP in Ruby 1.8 did not set the default certificate store # automatically when VERIFY_PEER was specified. if RUBY_VERSION < '1.9' && !http.ca_file && !http.ca_path && !http.cert_store http.cert_store = OpenSSL::SSL::SSLContext::DEFAULT_CERT_STORE end end get = Net::HTTP::Get.new(uri.request_uri) get.basic_auth uri.user, uri.password if uri.user @response = http.request(get) return response.body if response.is_a? Net::HTTPSuccess raise OneLogin::RubySaml::HttpError.new( "Failed to fetch idp metadata: #{response.code}: #{response.message}" ) end class IdpMetadata attr_reader :idpsso_descriptor, :entity_id def initialize(idpsso_descriptor, entity_id) @idpsso_descriptor = idpsso_descriptor @entity_id = entity_id end def to_hash(options = {}) sso_binding = options[:sso_binding] slo_binding = options[:slo_binding] { :idp_entity_id => @entity_id, :name_identifier_format => idp_name_id_format(options[:name_id_format]), :idp_sso_service_url => single_signon_service_url(sso_binding), :idp_sso_service_binding => single_signon_service_binding(sso_binding), :idp_slo_service_url => single_logout_service_url(slo_binding), :idp_slo_service_binding => single_logout_service_binding(slo_binding), :idp_slo_response_service_url => single_logout_response_service_url(slo_binding), :idp_attribute_names => attribute_names, :idp_cert => nil, :idp_cert_fingerprint => nil, :idp_cert_multi => nil, :valid_until => valid_until, :cache_duration => cache_duration, }.tap do |response_hash| merge_certificates_into(response_hash) unless certificates.nil? end end # @return [String|nil] 'validUntil' attribute of metadata # def valid_until root = @idpsso_descriptor.root root.attributes['validUntil'] if root && root.attributes end # @return [String|nil] 'cacheDuration' attribute of metadata # def cache_duration root = @idpsso_descriptor.root root.attributes['cacheDuration'] if root && root.attributes end # @param name_id_priority [String|Array] The prioritized list of NameIDFormat values to select. Will select first value if nil. # @return [String|nil] IdP NameIDFormat value if exists # def idp_name_id_format(name_id_priority = nil) nodes = REXML::XPath.match( @idpsso_descriptor, "md:NameIDFormat", SamlMetadata::NAMESPACE ) first_ranked_text(nodes, name_id_priority) end # @param binding_priority [String|Array] The prioritized list of Binding values to select. Will select first value if nil. # @return [String|nil] SingleSignOnService binding if exists # def single_signon_service_binding(binding_priority = nil) nodes = REXML::XPath.match( @idpsso_descriptor, "md:SingleSignOnService/@Binding", SamlMetadata::NAMESPACE ) first_ranked_value(nodes, binding_priority) end # @param binding_priority [String|Array] The prioritized list of Binding values to select. Will select first value if nil. # @return [String|nil] SingleLogoutService binding if exists # def single_logout_service_binding(binding_priority = nil) nodes = REXML::XPath.match( @idpsso_descriptor, "md:SingleLogoutService/@Binding", SamlMetadata::NAMESPACE ) first_ranked_value(nodes, binding_priority) end # @param binding_priority [String|Array] The prioritized list of Binding values to select. Will select first value if nil. # @return [String|nil] SingleSignOnService endpoint if exists # def single_signon_service_url(binding_priority = nil) binding = single_signon_service_binding(binding_priority) return if binding.nil? node = REXML::XPath.first( @idpsso_descriptor, "md:SingleSignOnService[@Binding=\"#{binding}\"]/@Location", SamlMetadata::NAMESPACE ) node.value if node end # @param binding_priority [String|Array] The prioritized list of Binding values to select. Will select first value if nil. # @return [String|nil] SingleLogoutService endpoint if exists # def single_logout_service_url(binding_priority = nil) binding = single_logout_service_binding(binding_priority) return if binding.nil? node = REXML::XPath.first( @idpsso_descriptor, "md:SingleLogoutService[@Binding=\"#{binding}\"]/@Location", SamlMetadata::NAMESPACE ) node.value if node end # @param binding_priority [String|Array] The prioritized list of Binding values to select. Will select first value if nil. # @return [String|nil] SingleLogoutService response url if exists # def single_logout_response_service_url(binding_priority = nil) binding = single_logout_service_binding(binding_priority) return if binding.nil? node = REXML::XPath.first( @idpsso_descriptor, "md:SingleLogoutService[@Binding=\"#{binding}\"]/@ResponseLocation", SamlMetadata::NAMESPACE ) node.value if node end # @return [String|nil] Unformatted Certificate if exists # def certificates @certificates ||= begin signing_nodes = REXML::XPath.match( @idpsso_descriptor, "md:KeyDescriptor[not(contains(@use, 'encryption'))]/ds:KeyInfo/ds:X509Data/ds:X509Certificate", SamlMetadata::NAMESPACE ) encryption_nodes = REXML::XPath.match( @idpsso_descriptor, "md:KeyDescriptor[not(contains(@use, 'signing'))]/ds:KeyInfo/ds:X509Data/ds:X509Certificate", SamlMetadata::NAMESPACE ) return nil if signing_nodes.empty? && encryption_nodes.empty? certs = {} unless signing_nodes.empty? certs['signing'] = [] signing_nodes.each do |cert_node| certs['signing'] << Utils.element_text(cert_node) end end unless encryption_nodes.empty? certs['encryption'] = [] encryption_nodes.each do |cert_node| certs['encryption'] << Utils.element_text(cert_node) end end certs end end # @return [String|nil] the fingerpint of the X509Certificate if it exists # def fingerprint(certificate, fingerprint_algorithm = XMLSecurity::Document::SHA1) @fingerprint ||= begin return unless certificate cert = OpenSSL::X509::Certificate.new(Base64.decode64(certificate)) fingerprint_alg = XMLSecurity::BaseDocument.new.algorithm(fingerprint_algorithm).new fingerprint_alg.hexdigest(cert.to_der).upcase.scan(/../).join(":") end end # @return [Array] the names of all SAML attributes if any exist # def attribute_names nodes = REXML::XPath.match( @idpsso_descriptor , "saml:Attribute/@Name", SamlMetadata::NAMESPACE ) nodes.map(&:value) end def merge_certificates_into(parsed_metadata) if (certificates.size == 1 && (certificates_has_one('signing') || certificates_has_one('encryption'))) || (certificates_has_one('signing') && certificates_has_one('encryption') && certificates["signing"][0] == certificates["encryption"][0]) if certificates.key?("signing") parsed_metadata[:idp_cert] = certificates["signing"][0] parsed_metadata[:idp_cert_fingerprint] = fingerprint( parsed_metadata[:idp_cert], parsed_metadata[:idp_cert_fingerprint_algorithm] ) else parsed_metadata[:idp_cert] = certificates["encryption"][0] parsed_metadata[:idp_cert_fingerprint] = fingerprint( parsed_metadata[:idp_cert], parsed_metadata[:idp_cert_fingerprint_algorithm] ) end end # symbolize keys of certificates and pass it on parsed_metadata[:idp_cert_multi] = Hash[certificates.map { |k, v| [k.to_sym, v] }] end def certificates_has_one(key) certificates.key?(key) && certificates[key].size == 1 end private def first_ranked_text(nodes, priority = nil) return unless nodes.any? priority = Array(priority) if priority.any? values = nodes.map(&:text) priority.detect { |candidate| values.include?(candidate) } else nodes.first.text end end def first_ranked_value(nodes, priority = nil) return unless nodes.any? priority = Array(priority) if priority.any? values = nodes.map(&:value) priority.detect { |candidate| values.include?(candidate) } else nodes.first.value end end end def merge_parsed_metadata_into(settings, parsed_metadata) parsed_metadata.each do |key, value| settings.send("#{key}=".to_sym, value) end settings end end end end