require "time" require "nokogiri" # Only supports SAML 2.0 module Maestrano module Saml class Response ASSERTION = "urn:oasis:names:tc:SAML:2.0:assertion" PROTOCOL = "urn:oasis:names:tc:SAML:2.0:protocol" DSIG = "http://www.w3.org/2000/09/xmldsig#" # TODO: This should probably be ctor initialized too... WDYT? attr_accessor :settings attr_reader :options attr_reader :response attr_reader :document def initialize(response, options = {}) raise ArgumentError.new("Response cannot be nil") if response.nil? @options = options @response = (response =~ /^ PROTOCOL, "a" => ASSERTION }) node.attributes["Value"] == "urn:oasis:names:tc:SAML:2.0:status:Success" end end # Conditions (if any) for the assertion to run def conditions @conditions ||= xpath_first_from_signed_assertion('/a:Conditions') end def not_before @not_before ||= parse_time(conditions, "NotBefore") end def not_on_or_after @not_on_or_after ||= parse_time(conditions, "NotOnOrAfter") end def issuer @issuer ||= begin node = REXML::XPath.first(document, "/p:Response/a:Issuer", { "p" => PROTOCOL, "a" => ASSERTION }) node ||= xpath_first_from_signed_assertion('/a:Issuer') node.nil? ? nil : node.text end end private def validation_error(message) raise ValidationError.new(message) end def validate(soft = true) validate_structure(soft) && validate_response_state(soft) && validate_conditions(soft) && document.validate_document(get_fingerprint, soft) && success? end def validate_structure(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(self.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 def validate_response_state(soft = true) if response.empty? return soft ? false : validation_error("Blank response") end if settings.nil? return soft ? false : validation_error("No settings on response") end if settings.idp_cert_fingerprint.nil? && settings.idp_cert.nil? return soft ? false : validation_error("No fingerprint or certificate on settings") end true end def xpath_first_from_signed_assertion(subelt=nil) 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 end def get_fingerprint if settings.idp_cert cert = OpenSSL::X509::Certificate.new(settings.idp_cert) Digest::SHA1.hexdigest(cert.to_der).upcase.scan(/../).join(":") else settings.idp_cert_fingerprint end end def validate_conditions(soft = true) return true if conditions.nil? return true if options[:skip_conditions] now = Time.now.utc if not_before && (now + (options[:allowed_clock_drift] || 0)) < not_before return soft ? false : validation_error("Current time is earlier than NotBefore condition") end if not_on_or_after && now >= not_on_or_after return soft ? false : validation_error("Current time is on or after NotOnOrAfter condition") end true end def parse_time(node, attribute) if node && node.attributes[attribute] Time.parse(node.attributes[attribute]) end end end end end