ruby-saml 0.4.7 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

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