ruby-saml 0.4.7 → 0.5.0

Sign up to get free protection for your applications and to get access to all the features.

Potentially problematic release.


This version of ruby-saml might be problematic. Click here for more details.

@@ -36,7 +36,9 @@ In the above there are a few assumptions in place, one being that the response.n
36
36
  settings.idp_sso_target_url = "https://app.onelogin.com/saml/signon/#{OneLoginAppId}"
37
37
  settings.idp_cert_fingerprint = OneLoginAppCertFingerPrint
38
38
  settings.name_identifier_format = "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress"
39
-
39
+ # Optional for most SAML IdPs
40
+ settings.authn_context = "urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport"
41
+
40
42
  settings
41
43
  end
42
44
 
@@ -70,7 +72,9 @@ What's left at this point, is to wrap it all up in a controller and point the in
70
72
  settings.idp_sso_target_url = "https://app.onelogin.com/saml/signon/#{OneLoginAppId}"
71
73
  settings.idp_cert_fingerprint = OneLoginAppCertFingerPrint
72
74
  settings.name_identifier_format = "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress"
73
-
75
+ # Optional for most SAML IdPs
76
+ settings.authn_context = "urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport"
77
+
74
78
  settings
75
79
  end
76
80
  end
@@ -83,6 +87,25 @@ contains all the saml:AttributeStatement with its 'Name' as a indifferent key an
83
87
 
84
88
  response.attributes[:username]
85
89
 
90
+ == Service Provider Metadata
91
+
92
+ To form a trusted pair relationship with the IdP, the SP (you) need to provide metadata XML
93
+ to the IdP for various good reasons. (Caching, certificate lookups, relying party permissions, etc)
94
+
95
+ The class Onelogin::Saml::Metdata takes care of this by reading the Settings and returning XML. All
96
+ you have to do is add a controller to return the data, then give this URL to the IdP administrator.
97
+ The metdata will be polled by the IdP every few minutes, so updating your settings should propagate
98
+ to the IdP settings.
99
+
100
+ class SamlController < ApplicationController
101
+ # ... the rest of your controller definitions ...
102
+ def metadata
103
+ settings = Account.get_saml_settings
104
+ meta = Onelogin::Saml::Metadata.new
105
+ render :xml => meta.create(settings)
106
+ end
107
+ end
108
+
86
109
 
87
110
  = Full Example
88
111
 
data/Rakefile CHANGED
@@ -1,27 +1,6 @@
1
1
  require 'rubygems'
2
2
  require 'rake'
3
3
 
4
- begin
5
- require 'jeweler'
6
- Jeweler::Tasks.new do |gem|
7
- gem.name = "ruby-saml"
8
- gem.summary = %Q{SAML Ruby Tookit}
9
- gem.description = %Q{SAML toolkit for Ruby on Rails}
10
- gem.email = "support@onelogin.com"
11
- gem.homepage = "http://github.com/onelogin/ruby-saml"
12
- gem.authors = ["OneLogin LLC"]
13
- gem.add_dependency("canonix","~> 0.1")
14
- gem.add_dependency("uuid","~> 2.3")
15
- gem.add_development_dependency "shoulda"
16
- gem.add_development_dependency "ruby-debug"
17
- gem.add_development_dependency "mocha"
18
- #gem is a Gem::Specification... see http://www.rubygems.org/read/chapter/20 for additional settings
19
- end
20
- Jeweler::GemcutterTasks.new
21
- rescue LoadError
22
- puts "Jeweler (or a dependency) not available. Install it with: sudo gem install jeweler"
23
- end
24
-
25
4
  #not being used yet.
26
5
  require 'rake/testtask'
27
6
  Rake::TestTask.new(:test) do |test|
@@ -43,7 +22,7 @@ rescue LoadError
43
22
  end
44
23
  end
45
24
 
46
- task :test => :check_dependencies
25
+ task :test
47
26
 
48
27
  task :default => :test
49
28
 
@@ -0,0 +1,74 @@
1
+ require "base64"
2
+ require "uuid"
3
+ require "zlib"
4
+ require "cgi"
5
+ require "rexml/document"
6
+ require "rexml/xpath"
7
+
8
+ module Onelogin
9
+ module Saml
10
+ include REXML
11
+ class Authrequest
12
+ def create(settings, params = {})
13
+ uuid = "_" + UUID.new.generate
14
+ time = Time.now.utc.strftime("%Y-%m-%dT%H:%M:%SZ")
15
+ # Create AuthnRequest root element using REXML
16
+ request_doc = REXML::Document.new
17
+
18
+ root = request_doc.add_element "samlp:AuthnRequest", { "xmlns:samlp" => "urn:oasis:names:tc:SAML:2.0:protocol" }
19
+ root.attributes['ID'] = uuid
20
+ root.attributes['IssueInstant'] = time
21
+ root.attributes['Version'] = "2.0"
22
+
23
+ # Conditionally defined elements based on settings
24
+ if settings.assertion_consumer_service_url != nil
25
+ root.attributes["AssertionConsumerServiceURL"] = settings.assertion_consumer_service_url
26
+ end
27
+ if settings.issuer != nil
28
+ issuer = root.add_element "saml:Issuer", { "xmlns:saml" => "urn:oasis:names:tc:SAML:2.0:assertion" }
29
+ issuer.text = settings.issuer
30
+ end
31
+ if settings.name_identifier_format != nil
32
+ root.add_element "samlp:NameIDPolicy", {
33
+ "xmlns:samlp" => "urn:oasis:names:tc:SAML:2.0:protocol",
34
+ # Might want to make AllowCreate a setting?
35
+ "AllowCreate" => "true",
36
+ "Format" => settings.name_identifier_format
37
+ }
38
+ end
39
+
40
+ # BUG fix here -- if an authn_context is defined, add the tags with an "exact"
41
+ # match required for authentication to succeed. If this is not defined,
42
+ # the IdP will choose default rules for authentication. (Shibboleth IdP)
43
+ if settings.authn_context != nil
44
+ requested_context = root.add_element "samlp:RequestedAuthnContext", {
45
+ "xmlns:samlp" => "urn:oasis:names:tc:SAML:2.0:protocol",
46
+ "Comparison" => "exact",
47
+ }
48
+ class_ref = requested_context.add_element "saml:AuthnContextClassRef", {
49
+ "xmlns:saml" => "urn:oasis:names:tc:SAML:2.0:assertion",
50
+ }
51
+ class_ref.text = settings.authn_context
52
+ end
53
+
54
+ request = ""
55
+ request_doc.write(request)
56
+
57
+ Logging.debug "Created AuthnRequest: #{request}"
58
+
59
+ deflated_request = Zlib::Deflate.deflate(request, 9)[2..-5]
60
+ base64_request = Base64.encode64(deflated_request)
61
+ encoded_request = CGI.escape(base64_request)
62
+ params_prefix = (settings.idp_sso_target_url =~ /\?/) ? '&' : '?'
63
+ request_params = "#{params_prefix}SAMLRequest=#{encoded_request}"
64
+
65
+ params.each_pair do |key, value|
66
+ request_params << "&#{key}=#{CGI.escape(value.to_s)}"
67
+ end
68
+
69
+ settings.idp_sso_target_url + request_params
70
+ end
71
+
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,22 @@
1
+ # Simplistic log class when we're running in Rails
2
+ module Onelogin
3
+ module Saml
4
+ class Logging
5
+ def self.debug(message)
6
+ if defined? Rails
7
+ Rails.logger.debug message
8
+ else
9
+ puts message
10
+ end
11
+ end
12
+
13
+ def self.info(message)
14
+ if defined? Rails
15
+ Rails.logger.info message
16
+ else
17
+ puts message
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,47 @@
1
+ require "rexml/document"
2
+ require "rexml/xpath"
3
+ require "uri"
4
+
5
+ # Class to return SP metadata based on the settings requested.
6
+ # Return this XML in a controller, then give that URL to the the
7
+ # IdP administrator. The IdP will poll the URL and your settings
8
+ # will be updated automatically
9
+ module Onelogin
10
+ module Saml
11
+ include REXML
12
+ class Metadata
13
+ def generate(settings)
14
+ meta_doc = REXML::Document.new
15
+ root = meta_doc.add_element "md:EntityDescriptor", {
16
+ "xmlns:md" => "urn:oasis:names:tc:SAML:2.0:metadata"
17
+ }
18
+ sp_sso = root.add_element "md:SPSSODescriptor", {
19
+ "protocolSupportEnumeration" => "urn:oasis:names:tc:SAML:2.0:protocol"
20
+ }
21
+ if settings.issuer != nil
22
+ root.attributes["entityID"] = settings.issuer
23
+ end
24
+ if settings.name_identifier_format != nil
25
+ name_id = sp_sso.add_element "md:NameIDFormat"
26
+ name_id.text = settings.name_identifier_format
27
+ end
28
+ if settings.assertion_consumer_service_url != nil
29
+ sp_sso.add_element "md:AssertionConsumerService", {
30
+ # Add this as a setting to create different bindings?
31
+ "Binding" => "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST",
32
+ "Location" => settings.assertion_consumer_service_url
33
+ }
34
+ end
35
+ meta_doc << REXML::XMLDecl.new
36
+ ret = ""
37
+ # pretty print the XML so IdP administrators can easily see what the SP supports
38
+ meta_doc.write(ret, 1)
39
+
40
+ Logging.debug "Generated metadata:\n#{ret}"
41
+
42
+ return ret
43
+
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,146 @@
1
+ require "xml_security"
2
+ require "time"
3
+
4
+ module Onelogin
5
+ module Saml
6
+
7
+ class Response
8
+ ASSERTION = "urn:oasis:names:tc:SAML:2.0:assertion"
9
+ PROTOCOL = "urn:oasis:names:tc:SAML:2.0:protocol"
10
+ DSIG = "http://www.w3.org/2000/09/xmldsig#"
11
+
12
+ attr_accessor :options, :response, :document, :settings
13
+
14
+ def initialize(response, options = {})
15
+ raise ArgumentError.new("Response cannot be nil") if response.nil?
16
+ self.options = options
17
+ self.response = response
18
+ self.document = XMLSecurity::SignedDocument.new(Base64.decode64(response))
19
+ end
20
+
21
+ def is_valid?
22
+ validate(soft = true)
23
+ end
24
+
25
+ def validate!
26
+ validate(soft = false)
27
+ end
28
+
29
+ # The value of the user identifier as designated by the initialization request response
30
+ def name_id
31
+ @name_id ||= begin
32
+ 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 })
33
+ 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 })
34
+ node.nil? ? nil : node.text
35
+ end
36
+ end
37
+
38
+ # A hash of alle the attributes with the response. Assuming there is only one value for each key
39
+ def attributes
40
+ @attr_statements ||= begin
41
+ result = {}
42
+
43
+ stmt_element = REXML::XPath.first(document, "/p:Response/a:Assertion/a:AttributeStatement", { "p" => PROTOCOL, "a" => ASSERTION })
44
+ return {} if stmt_element.nil?
45
+
46
+ stmt_element.elements.each do |attr_element|
47
+ name = attr_element.attributes["Name"]
48
+ value = attr_element.elements.first.text
49
+
50
+ result[name] = value
51
+ end
52
+
53
+ result.keys.each do |key|
54
+ result[key.intern] = result[key]
55
+ end
56
+
57
+ result
58
+ end
59
+ end
60
+
61
+ # When this user session should expire at latest
62
+ def session_expires_at
63
+ @expires_at ||= begin
64
+ node = REXML::XPath.first(document, "/p:Response/a:Assertion/a:AuthnStatement", { "p" => PROTOCOL, "a" => ASSERTION })
65
+ parse_time(node, "SessionNotOnOrAfter")
66
+ end
67
+ end
68
+
69
+ # Conditions (if any) for the assertion to run
70
+ def conditions
71
+ @conditions ||= begin
72
+ 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 })
73
+ end
74
+ end
75
+
76
+ def issuer
77
+ @issuer ||= begin
78
+ node = REXML::XPath.first(document, "/p:Response/a:Issuer", { "p" => PROTOCOL, "a" => ASSERTION })
79
+ node.nil? ? nil : node.text
80
+ end
81
+ end
82
+
83
+ private
84
+
85
+ def validation_error(message)
86
+ raise ValidationError.new(message)
87
+ end
88
+
89
+ def validate(soft = true)
90
+ validate_response_state(soft) &&
91
+ validate_conditions(soft) &&
92
+ document.validate(get_fingerprint, soft)
93
+ end
94
+
95
+ def validate_response_state(soft = true)
96
+ if response.empty?
97
+ return soft ? false : validation_error("Blank response")
98
+ end
99
+
100
+ if settings.nil?
101
+ return soft ? false : validation_error("No settings on response")
102
+ end
103
+
104
+ if settings.idp_cert_fingerprint.nil? && settings.idp_cert.nil?
105
+ return soft ? false : validation_error("No fingerprint or certificate on settings")
106
+ end
107
+
108
+ true
109
+ end
110
+
111
+ def get_fingerprint
112
+ if settings.idp_cert
113
+ cert = OpenSSL::X509::Certificate.new(settings.idp_cert)
114
+ Digest::SHA1.hexdigest(cert.to_der).upcase.scan(/../).join(":")
115
+ else
116
+ settings.idp_cert_fingerprint
117
+ end
118
+ end
119
+
120
+ def validate_conditions(soft = true)
121
+ return true if conditions.nil?
122
+ return true if options[:skip_conditions]
123
+
124
+ if not_before = parse_time(conditions, "NotBefore")
125
+ if Time.now.utc < not_before
126
+ return soft ? false : validation_error("Current time is earlier than NotBefore condition")
127
+ end
128
+ end
129
+
130
+ if not_on_or_after = parse_time(conditions, "NotOnOrAfter")
131
+ if Time.now.utc >= not_on_or_after
132
+ return soft ? false : validation_error("Current time is on or after NotOnOrAfter condition")
133
+ end
134
+ end
135
+
136
+ true
137
+ end
138
+
139
+ def parse_time(node, attribute)
140
+ if node && node.attributes[attribute]
141
+ Time.parse(node.attributes[attribute])
142
+ end
143
+ end
144
+ end
145
+ end
146
+ end
@@ -0,0 +1,9 @@
1
+ module Onelogin
2
+ module Saml
3
+ class Settings
4
+ attr_accessor :assertion_consumer_service_url, :issuer, :sp_name_qualifier
5
+ attr_accessor :idp_sso_target_url, :idp_cert_fingerprint, :idp_cert, :name_identifier_format
6
+ attr_accessor :authn_context
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,5 @@
1
+ module Onelogin
2
+ module Saml
3
+ VERSION = '0.5.0'
4
+ end
5
+ end
@@ -1,5 +1,7 @@
1
- module Onelogin
2
- end
3
-
4
- require 'onelogin/saml'
5
-
1
+ require 'onelogin/ruby-saml/logging'
2
+ require 'onelogin/ruby-saml/authrequest'
3
+ require 'onelogin/ruby-saml/response'
4
+ require 'onelogin/ruby-saml/settings'
5
+ require 'onelogin/ruby-saml/validation_error'
6
+ require 'onelogin/ruby-saml/metadata'
7
+ require 'onelogin/ruby-saml/version'
@@ -44,7 +44,7 @@ module XMLSecurity
44
44
 
45
45
  def validate(idp_cert_fingerprint, soft = true)
46
46
  # get cert from response
47
- base64_cert = self.elements["//ds:X509Certificate"].text
47
+ base64_cert = REXML::XPath.first(self, "//ds:X509Certificate").text
48
48
  cert_text = Base64.decode64(base64_cert)
49
49
  cert = OpenSSL::X509::Certificate.new(cert_text)
50
50
 
@@ -81,11 +81,11 @@ module XMLSecurity
81
81
  hashed_element = REXML::XPath.first(self, "//[@ID='#{uri[1,uri.size]}']")
82
82
  canoner = XML::Util::XmlCanonicalizer.new(false, true)
83
83
  canoner.inclusive_namespaces = inclusive_namespaces if canoner.respond_to?(:inclusive_namespaces) && !inclusive_namespaces.empty?
84
- canon_hashed_element = canoner.canonicalize(hashed_element)
84
+ canon_hashed_element = canoner.canonicalize(hashed_element).gsub('&','&amp;')
85
85
  hash = Base64.encode64(Digest::SHA1.digest(canon_hashed_element)).chomp
86
86
  digest_value = REXML::XPath.first(ref, "//ds:DigestValue", {"ds"=>"http://www.w3.org/2000/09/xmldsig#"}).text
87
87
 
88
- if hash != digest_value
88
+ unless digests_match?(hash, digest_value)
89
89
  return soft ? false : (raise Onelogin::Saml::ValidationError.new("Digest mismatch"))
90
90
  end
91
91
  end
@@ -111,6 +111,10 @@ module XMLSecurity
111
111
 
112
112
  private
113
113
 
114
+ def digests_match?(hash, digest_value)
115
+ hash == digest_value
116
+ end
117
+
114
118
  def extract_signed_element_id
115
119
  reference_element = REXML::XPath.first(self, "//ds:Signature/ds:SignedInfo/ds:Reference", {"ds"=>DSIG})
116
120
  self.signed_element_id = reference_element.attribute("URI").value unless reference_element.nil?
@@ -1,11 +1,9 @@
1
- # Generated by jeweler
2
- # DO NOT EDIT THIS FILE DIRECTLY
3
- # Instead, edit Jeweler::Tasks in Rakefile, and run the gemspec command
4
- # -*- encoding: utf-8 -*-
1
+ $LOAD_PATH.push File.expand_path('../lib', __FILE__)
2
+ require 'onelogin/ruby-saml/version'
5
3
 
6
4
  Gem::Specification.new do |s|
7
- s.name = %q{ruby-saml}
8
- s.version = "0.4.7"
5
+ s.name = 'ruby-saml'
6
+ s.version = Onelogin::Saml::VERSION
9
7
 
10
8
  s.required_rubygems_version = Gem::Requirement.new(">= 0") if s.respond_to? :required_rubygems_version=
11
9
  s.authors = ["OneLogin LLC"]
@@ -16,48 +14,14 @@ Gem::Specification.new do |s|
16
14
  "LICENSE",
17
15
  "README.rdoc"
18
16
  ]
19
- s.files = [
20
- ".document",
21
- ".gitignore",
22
- "LICENSE",
23
- "README.rdoc",
24
- "Rakefile",
25
- "VERSION",
26
- "lib/onelogin/saml.rb",
27
- "lib/onelogin/saml/authrequest.rb",
28
- "lib/onelogin/saml/response.rb",
29
- "lib/onelogin/saml/settings.rb",
30
- "lib/onelogin/saml/validation_error.rb",
31
- "lib/ruby-saml.rb",
32
- "lib/xml_security.rb",
33
- "ruby-saml.gemspec",
34
- "test/certificates/certificate1",
35
- "test/request_test.rb",
36
- "test/response_test.rb",
37
- "test/responses/adfs_response.xml.base64",
38
- "test/responses/open_saml_response.xml",
39
- "test/responses/response1.xml.base64",
40
- "test/responses/response2.xml.base64",
41
- "test/responses/response3.xml.base64",
42
- "test/responses/response4.xml.base64",
43
- "test/responses/response5.xml.base64",
44
- "test/responses/simple_saml_php.xml",
45
- "test/settings_test.rb",
46
- "test/test_helper.rb",
47
- "test/xml_security_test.rb"
48
- ]
17
+ s.files = `git ls-files`.split("\n")
49
18
  s.homepage = %q{http://github.com/onelogin/ruby-saml}
19
+ s.rubyforge_project = %q{http://www.rubygems.org/gems/ruby-saml}
50
20
  s.rdoc_options = ["--charset=UTF-8"]
51
21
  s.require_paths = ["lib"]
52
22
  s.rubygems_version = %q{1.3.7}
53
23
  s.summary = %q{SAML Ruby Tookit}
54
- s.test_files = [
55
- "test/request_test.rb",
56
- "test/response_test.rb",
57
- "test/settings_test.rb",
58
- "test/test_helper.rb",
59
- "test/xml_security_test.rb"
60
- ]
24
+ s.test_files = `git ls-files test/*`.split("\n")
61
25
 
62
26
  if s.respond_to? :specification_version then
63
27
  current_version = Gem::Specification::CURRENT_SPECIFICATION_VERSION