Skip to content

Commit

Permalink
Merge pull request omniauth#445 from raecoo/master
Browse files Browse the repository at this point in the history
Add SAML strategy
  • Loading branch information
Michael Bleigh committed Aug 29, 2011
2 parents abe1d1c + e8de4f2 commit 54a73ca
Show file tree
Hide file tree
Showing 9 changed files with 439 additions and 3 deletions.
35 changes: 34 additions & 1 deletion oa-enterprise/README.rdoc
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions oa-enterprise/lib/omniauth/enterprise.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
50 changes: 50 additions & 0 deletions oa-enterprise/lib/omniauth/strategies/saml.rb
Original file line number Diff line number Diff line change
@@ -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
38 changes: 38 additions & 0 deletions oa-enterprise/lib/omniauth/strategies/saml/auth_request.rb
Original file line number Diff line number Diff line change
@@ -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
141 changes: 141 additions & 0 deletions oa-enterprise/lib/omniauth/strategies/saml/auth_response.rb
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
module OmniAuth
module Strategies
class SAML
class ValidationError < Exception
end
end
end
end
Loading

0 comments on commit 54a73ca

Please sign in to comment.