Skip to content

HTTPS clone URL

Subversion checkout URL

You can clone with HTTPS or Subversion.

Download ZIP
Browse files

adds SAML strategy

  • Loading branch information...
commit 3079ffcdaefcbc0ed588bd1d24ee39da9940a058 1 parent abe1d1c
@raecoo raecoo authored
View
35 oa-enterprise/README.rdoc
@@ -66,7 +66,40 @@ are not familiar with these authentication methods, please just avoid them.
Direct users to '/auth/ldap' to have them authenticated via your
company's LDAP server.
-
+
+== SAML
+
+Use the SAML strategy as a middleware in your application:
+
+ require 'omniauth/enterprise'
+ use OmniAuth::Strategies::SAML,
+ :assertion_consumer_service_url => "consumer_service_url",
+ :issuer => "issuer",
+ :idp_sso_target_url => "idp_sso_target_url",
+ :idp_cert_fingerprint => "E7:91:B2:E1:...",
+ :name_identifier_format => "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress"
+
+:assertion_consumer_service_url
+ The URL at which the SAML assertion should be received.
+
+:issuer
+ The name of your application. Some identity providers might need this to establish the
+ identity of the service provider requesting the login.
+
+:idp_sso_target_url
+ The URL to which the authentication request should be sent. This would be on the identity provider.
+
+:idp_cert_fingerprint
+ The certificate fingerprint, e.g. "90:CC:16:F0:8D:A6:D1:C6:BB:27:2D:BA:93:80:1A:1F:16:8E:4E:08".
+ This is provided from the identity provider when setting up the relationship.
+
+:name_identifier_format
+ Describes the format of the username required by this application.
+ If you need the email address, use "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress".
+ See http://docs.oasis-open.org/security/saml/v2.0/saml-core-2.0-os.pdf section 8.3 for
+ other options. Note that the identity provider might not support all options.
+
+
== Multiple Strategies
If you're using multiple strategies together, use OmniAuth's Builder. That's
View
1  oa-enterprise/lib/omniauth/enterprise.rb
@@ -4,5 +4,6 @@ module OmniAuth
module Strategies
autoload :CAS, 'omniauth/strategies/cas'
autoload :LDAP, 'omniauth/strategies/ldap'
+ autoload :SAML, 'omniauth/strategies/saml'
end
end
View
50 oa-enterprise/lib/omniauth/strategies/saml.rb
@@ -0,0 +1,50 @@
+require 'omniauth/enterprise'
+
+module OmniAuth
+ module Strategies
+ class SAML
+ include OmniAuth::Strategy
+ autoload :AuthRequest, 'omniauth/strategies/saml/auth_request'
+ autoload :AuthResponse, 'omniauth/strategies/saml/auth_response'
+ autoload :ValidationError, 'omniauth/strategies/saml/validation_error'
+ autoload :XMLSecurity, 'omniauth/strategies/saml/xml_security'
+
+ @@settings = {}
+
+ def initialize(app, options={})
+ super(app, :saml)
+ @@settings = {
+ :assertion_consumer_service_url => options[:assertion_consumer_service_url],
+ :issuer => options[:issuer],
+ :idp_sso_target_url => options[:idp_sso_target_url],
+ :idp_cert_fingerprint => options[:idp_cert_fingerprint],
+ :name_identifier_format => options[:name_identifier_format] || "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress"
+ }
+ end
+
+ def request_phase
+ request = OmniAuth::Strategies::SAML::AuthRequest.new
+ redirect(request.create(@@settings))
+ end
+
+ def callback_phase
+ begin
+ response = OmniAuth::Strategies::SAML::AuthResponse.new(request.params['SAMLResponse'])
+ response.settings = @@settings
+ @name_id = response.name_id
+ return fail!(:invalid_ticket, 'Invalid SAML Ticket') if @name_id.nil? || @name_id.empty?
+ super
+ rescue ArgumentError => e
+ fail!(:invalid_ticket, 'Invalid SAML Response')
+ end
+ end
+
+ def auth_hash
+ OmniAuth::Utils.deep_merge(super, {
+ 'uid' => @name_id
+ })
+ end
+
+ end
+ end
+end
View
38 oa-enterprise/lib/omniauth/strategies/saml/auth_request.rb
@@ -0,0 +1,38 @@
+require "base64"
+require "uuid"
+require "zlib"
+require "cgi"
+
+module OmniAuth
+ module Strategies
+ class SAML
+ class AuthRequest
+
+ def create(settings, params = {})
+ uuid = "_" + UUID.new.generate
+ time = Time.now.utc.strftime("%Y-%m-%dT%H:%M:%SZ")
+
+ request =
+ "<samlp:AuthnRequest xmlns:samlp=\"urn:oasis:names:tc:SAML:2.0:protocol\" ID=\"#{uuid}\" Version=\"2.0\" IssueInstant=\"#{time}\" ProtocolBinding=\"urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST\" AssertionConsumerServiceURL=\"#{settings[:assertion_consumer_service_url]}\">" +
+ "<saml:Issuer xmlns:saml=\"urn:oasis:names:tc:SAML:2.0:assertion\">#{settings[:issuer]}</saml:Issuer>\n" +
+ "<samlp:NameIDPolicy xmlns:samlp=\"urn:oasis:names:tc:SAML:2.0:protocol\" Format=\"#{settings[:name_identifier_format]}\" AllowCreate=\"true\"></samlp:NameIDPolicy>\n" +
+ "<samlp:RequestedAuthnContext xmlns:samlp=\"urn:oasis:names:tc:SAML:2.0:protocol\" Comparison=\"exact\">" +
+ "<saml:AuthnContextClassRef xmlns:saml=\"urn:oasis:names:tc:SAML:2.0:assertion\">urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport</saml:AuthnContextClassRef></samlp:RequestedAuthnContext>\n" +
+ "</samlp:AuthnRequest>"
+
+ deflated_request = Zlib::Deflate.deflate(request, 9)[2..-5]
+ base64_request = Base64.encode64(deflated_request)
+ encoded_request = CGI.escape(base64_request)
+ request_params = "?SAMLRequest=" + encoded_request
+
+ params.each_pair do |key, value|
+ request_params << "&#{key}=#{CGI.escape(value.to_s)}"
+ end
+
+ settings[:idp_sso_target_url] + request_params
+ end
+
+ end
+ end
+ end
+end
View
141 oa-enterprise/lib/omniauth/strategies/saml/auth_response.rb
@@ -0,0 +1,141 @@
+require "time"
+
+module OmniAuth
+ module Strategies
+ class SAML
+ class AuthResponse
+
+ 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#"
+
+ attr_accessor :options, :response, :document, :settings
+
+ def initialize(response, options = {})
+ raise ArgumentError.new("Response cannot be nil") if response.nil?
+ self.options = options
+ self.response = response
+ self.document = OmniAuth::Strategies::SAML::XMLSecurity::SignedDocument.new(Base64.decode64(response))
+ end
+
+ def is_valid?
+ validate(soft = true)
+ end
+
+ def validate!
+ validate(soft = false)
+ end
+
+ # The value of the user identifier as designated by the initialization request response
+ def name_id
+ @name_id ||= begin
+ node = REXML::XPath.first(document, "/p:Response/a:Assertion[@ID='#{document.signed_element_id[1,document.signed_element_id.size]}']/a:Subject/a:NameID", { "p" => PROTOCOL, "a" => ASSERTION })
+ node ||= REXML::XPath.first(document, "/p:Response[@ID='#{document.signed_element_id[1,document.signed_element_id.size]}']/a:Assertion/a:Subject/a:NameID", { "p" => PROTOCOL, "a" => ASSERTION })
+ node.nil? ? nil : node.text
+ end
+ end
+
+ # A hash of alle the attributes with the response. Assuming there is only one value for each key
+ def attributes
+ @attr_statements ||= begin
+ result = {}
+
+ stmt_element = REXML::XPath.first(document, "/p:Response/a:Assertion/a:AttributeStatement", { "p" => PROTOCOL, "a" => ASSERTION })
+ return {} if stmt_element.nil?
+
+ stmt_element.elements.each do |attr_element|
+ name = attr_element.attributes["Name"]
+ value = attr_element.elements.first.text
+
+ result[name] = value
+ end
+
+ result.keys.each do |key|
+ result[key.intern] = result[key]
+ end
+
+ result
+ end
+ end
+
+ # When this user session should expire at latest
+ def session_expires_at
+ @expires_at ||= begin
+ node = REXML::XPath.first(document, "/p:Response/a:Assertion/a:AuthnStatement", { "p" => PROTOCOL, "a" => ASSERTION })
+ parse_time(node, "SessionNotOnOrAfter")
+ end
+ end
+
+ # Conditions (if any) for the assertion to run
+ def conditions
+ @conditions ||= begin
+ REXML::XPath.first(document, "/p:Response/a:Assertion[@ID='#{document.signed_element_id[1,document.signed_element_id.size]}']/a:Conditions", { "p" => PROTOCOL, "a" => ASSERTION })
+ end
+ end
+
+ private
+
+ def validation_error(message)
+ raise OmniAuth::Strategies::SAML::ValidationError.new(message)
+ end
+
+ def validate(soft = true)
+ validate_response_state(soft) &&
+ validate_conditions(soft) &&
+ document.validate(get_fingerprint, soft)
+ 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 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]
+
+ if not_before = parse_time(conditions, "NotBefore")
+ if Time.now.utc < not_before
+ return soft ? false : validation_error("Current time is earlier than NotBefore condition")
+ end
+ end
+
+ if not_on_or_after = parse_time(conditions, "NotOnOrAfter")
+ if Time.now.utc >= not_on_or_after
+ return soft ? false : validation_error("Current time is on or after NotOnOrAfter condition")
+ end
+ end
+
+ true
+ end
+
+ def parse_time(node, attribute)
+ if node && node.attributes[attribute]
+ Time.parse(node.attributes[attribute])
+ end
+ end
+
+ end
+ end
+ end
+end
View
8 oa-enterprise/lib/omniauth/strategies/saml/validation_error.rb
@@ -0,0 +1,8 @@
+module OmniAuth
+ module Strategies
+ class SAML
+ class ValidationError < Exception
+ end
+ end
+ end
+end
View
126 oa-enterprise/lib/omniauth/strategies/saml/xml_security.rb
@@ -0,0 +1,126 @@
+# The contents of this file are subject to the terms
+# of the Common Development and Distribution License
+# (the License). You may not use this file except in
+# compliance with the License.
+#
+# You can obtain a copy of the License at
+# https://opensso.dev.java.net/public/CDDLv1.0.html or
+# opensso/legal/CDDLv1.0.txt
+# See the License for the specific language governing
+# permission and limitations under the License.
+#
+# When distributing Covered Code, include this CDDL
+# Header Notice in each file and include the License file
+# at opensso/legal/CDDLv1.0.txt.
+# If applicable, add the following below the CDDL Header,
+# with the fields enclosed by brackets [] replaced by
+# your own identifying information:
+# "Portions Copyrighted [year] [name of copyright owner]"
+#
+# $Id: xml_sec.rb,v 1.6 2007/10/24 00:28:41 todddd Exp $
+#
+# Copyright 2007 Sun Microsystems Inc. All Rights Reserved
+# Portions Copyrighted 2007 Todd W Saxton.
+
+require 'rubygems'
+require "rexml/document"
+require "rexml/xpath"
+require "openssl"
+require "xmlcanonicalizer"
+require "digest/sha1"
+
+module OmniAuth
+ module Strategies
+ class SAML
+
+ module XMLSecurity
+
+ class SignedDocument < REXML::Document
+ DSIG = "http://www.w3.org/2000/09/xmldsig#"
+
+ attr_accessor :signed_element_id
+
+ def initialize(response)
+ super(response)
+ extract_signed_element_id
+ end
+
+ def validate(idp_cert_fingerprint, soft = true)
+ # get cert from response
+ base64_cert = self.elements["//ds:X509Certificate"].text
+ cert_text = Base64.decode64(base64_cert)
+ cert = OpenSSL::X509::Certificate.new(cert_text)
+
+ # check cert matches registered idp cert
+ fingerprint = Digest::SHA1.hexdigest(cert.to_der)
+
+ if fingerprint != idp_cert_fingerprint.gsub(/[^a-zA-Z0-9]/,"").downcase
+ return soft ? false : (raise OmniAuth::Strategies::SAML::ValidationError.new("Fingerprint mismatch"))
+ end
+
+ validate_doc(base64_cert, soft)
+ end
+
+ def validate_doc(base64_cert, soft = true)
+ # validate references
+
+ # check for inclusive namespaces
+
+ inclusive_namespaces = []
+ inclusive_namespace_element = REXML::XPath.first(self, "//ec:InclusiveNamespaces")
+
+ if inclusive_namespace_element
+ prefix_list = inclusive_namespace_element.attributes.get_attribute('PrefixList').value
+ inclusive_namespaces = prefix_list.split(" ")
+ end
+
+ # remove signature node
+ sig_element = REXML::XPath.first(self, "//ds:Signature", {"ds"=>"http://www.w3.org/2000/09/xmldsig#"})
+ sig_element.remove
+
+ # check digests
+ REXML::XPath.each(sig_element, "//ds:Reference", {"ds"=>"http://www.w3.org/2000/09/xmldsig#"}) do |ref|
+ uri = ref.attributes.get_attribute("URI").value
+ hashed_element = REXML::XPath.first(self, "//[@ID='#{uri[1,uri.size]}']")
+ canoner = XML::Util::XmlCanonicalizer.new(false, true)
+ canoner.inclusive_namespaces = inclusive_namespaces if canoner.respond_to?(:inclusive_namespaces) && !inclusive_namespaces.empty?
+ canon_hashed_element = canoner.canonicalize(hashed_element)
+ hash = Base64.encode64(Digest::SHA1.digest(canon_hashed_element)).chomp
+ digest_value = REXML::XPath.first(ref, "//ds:DigestValue", {"ds"=>"http://www.w3.org/2000/09/xmldsig#"}).text
+
+ if hash != digest_value
+ return soft ? false : (raise OmniAuth::Strategies::SAML::ValidationError.new("Digest mismatch"))
+ end
+ end
+
+ # verify signature
+ canoner = XML::Util::XmlCanonicalizer.new(false, true)
+ signed_info_element = REXML::XPath.first(sig_element, "//ds:SignedInfo", {"ds"=>"http://www.w3.org/2000/09/xmldsig#"})
+ canon_string = canoner.canonicalize(signed_info_element)
+
+ base64_signature = REXML::XPath.first(sig_element, "//ds:SignatureValue", {"ds"=>"http://www.w3.org/2000/09/xmldsig#"}).text
+ signature = Base64.decode64(base64_signature)
+
+ # get certificate object
+ cert_text = Base64.decode64(base64_cert)
+ cert = OpenSSL::X509::Certificate.new(cert_text)
+
+ if !cert.public_key.verify(OpenSSL::Digest::SHA1.new, signature, canon_string)
+ return soft ? false : (raise OmniAuth::Strategies::SAML::ValidationError.new("Key validation error"))
+ end
+
+ return true
+ end
+
+ private
+
+ def extract_signed_element_id
+ reference_element = REXML::XPath.first(self, "//ds:Signature/ds:SignedInfo/ds:Reference", {"ds"=>DSIG})
+ self.signed_element_id = reference_element.attribute("URI").value unless reference_element.nil?
+ end
+ end
+ end
+
+ end
+ end
+end
View
5 oa-enterprise/oa-enterprise.gemspec
@@ -8,6 +8,7 @@ Gem::Specification.new do |gem|
gem.add_dependency 'oa-core', OmniAuth::Version::STRING
gem.add_dependency 'pyu-ruby-sasl', '~> 0.0.3.1'
gem.add_dependency 'rubyntlm', '~> 0.1.1'
+ gem.add_dependency 'XMLCanonicalizer', '~> 1.0.1'
gem.add_development_dependency 'rack-test', '~> 0.5'
gem.add_development_dependency 'rake', '~> 0.8'
gem.add_development_dependency 'rdiscount', '~> 1.6'
@@ -15,9 +16,9 @@ Gem::Specification.new do |gem|
gem.add_development_dependency 'simplecov', '~> 0.4'
gem.add_development_dependency 'webmock', '~> 1.7'
gem.add_development_dependency 'yard', '~> 0.7'
- gem.authors = ['James A. Rosen', 'Ping Yu', 'Michael Bleigh', 'Erik Michaels-Ober']
+ gem.authors = ['James A. Rosen', 'Ping Yu', 'Michael Bleigh', 'Erik Michaels-Ober', 'Raecoo Cao']
gem.description = %q{Enterprise strategies for OmniAuth.}
- gem.email = ['james.a.rosen@gmail.com', 'ping@intridea.com', 'michael@intridea.com', 'sferik@gmail.com']
+ gem.email = ['james.a.rosen@gmail.com', 'ping@intridea.com', 'michael@intridea.com', 'sferik@gmail.com', 'raecoo@intridea.com']
gem.files = `git ls-files`.split("\n")
gem.homepage = 'http://github.com/intridea/omniauth'
gem.name = 'oa-enterprise'
Please sign in to comment.
Something went wrong with that request. Please try again.