Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

Already on GitHub? Sign in to your account

Add SAML strategy #445

Merged
merged 2 commits into from Aug 29, 2011
Jump to file or symbol
Failed to load files and symbols.
+439 −3
Split
View
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -0,0 +1,8 @@
+module OmniAuth
+ module Strategies
+ class SAML
+ class ValidationError < Exception
+ end
+ end
+ end
+end
Oops, something went wrong.