ruby-saml-federa 0.0.2

Sign up to get free protection for your applications and to get access to all the features.
Files changed (48) hide show
  1. data/.document +5 -0
  2. data/.gitignore +10 -0
  3. data/.travis.yml +5 -0
  4. data/Gemfile +12 -0
  5. data/LICENSE +19 -0
  6. data/README.md +124 -0
  7. data/Rakefile +41 -0
  8. data/lib/federa/ruby-saml/authrequest.rb +181 -0
  9. data/lib/federa/ruby-saml/coding.rb +34 -0
  10. data/lib/federa/ruby-saml/logging.rb +26 -0
  11. data/lib/federa/ruby-saml/logout_request.rb +126 -0
  12. data/lib/federa/ruby-saml/logout_response.rb +132 -0
  13. data/lib/federa/ruby-saml/metadata.rb +266 -0
  14. data/lib/federa/ruby-saml/request.rb +81 -0
  15. data/lib/federa/ruby-saml/response.rb +203 -0
  16. data/lib/federa/ruby-saml/settings.rb +28 -0
  17. data/lib/federa/ruby-saml/validation_error.rb +7 -0
  18. data/lib/federa/ruby-saml/version.rb +5 -0
  19. data/lib/ruby-saml-federa.rb +11 -0
  20. data/lib/schemas/saml20assertion_schema.xsd +283 -0
  21. data/lib/schemas/saml20protocol_schema.xsd +302 -0
  22. data/lib/schemas/xenc_schema.xsd +146 -0
  23. data/lib/schemas/xmldsig_schema.xsd +318 -0
  24. data/lib/xml_security.rb +165 -0
  25. data/ruby-saml-federa.gemspec +21 -0
  26. data/test/certificates/certificate1 +12 -0
  27. data/test/logoutrequest_test.rb +98 -0
  28. data/test/request_test.rb +53 -0
  29. data/test/response_test.rb +219 -0
  30. data/test/responses/adfs_response_sha1.xml +46 -0
  31. data/test/responses/adfs_response_sha256.xml +46 -0
  32. data/test/responses/adfs_response_sha384.xml +46 -0
  33. data/test/responses/adfs_response_sha512.xml +46 -0
  34. data/test/responses/no_signature_ns.xml +48 -0
  35. data/test/responses/open_saml_response.xml +56 -0
  36. data/test/responses/response1.xml.base64 +1 -0
  37. data/test/responses/response2.xml.base64 +79 -0
  38. data/test/responses/response3.xml.base64 +66 -0
  39. data/test/responses/response4.xml.base64 +93 -0
  40. data/test/responses/response5.xml.base64 +102 -0
  41. data/test/responses/response_with_ampersands.xml +139 -0
  42. data/test/responses/response_with_ampersands.xml.base64 +93 -0
  43. data/test/responses/simple_saml_php.xml +71 -0
  44. data/test/responses/wrapped_response_2.xml.base64 +150 -0
  45. data/test/settings_test.rb +43 -0
  46. data/test/test_helper.rb +66 -0
  47. data/test/xml_security_test.rb +123 -0
  48. metadata +155 -0
data/.document ADDED
@@ -0,0 +1,5 @@
1
+ README.rdoc
2
+ lib/**/*.rb
3
+ bin/*
4
+ features/**/*.feature
5
+ LICENSE
data/.gitignore ADDED
@@ -0,0 +1,10 @@
1
+ *.sw?
2
+ .DS_Store
3
+ coverage
4
+ rdoc
5
+ pkg
6
+ Gemfile.lock
7
+ .idea/*
8
+ lib/Lib.iml
9
+ test/Test.iml
10
+ .rvmrc
data/.travis.yml ADDED
@@ -0,0 +1,5 @@
1
+ language: ruby
2
+ rvm:
3
+ - 1.8.7
4
+ - 1.9.3
5
+ - ree
data/Gemfile ADDED
@@ -0,0 +1,12 @@
1
+ source 'http://rubygems.org'
2
+
3
+ gemspec
4
+
5
+ group :test do
6
+ gem "ruby-debug", "~> 0.10.4", :require => nil, :platforms => :ruby_18
7
+ gem "debugger", "~> 1.1.1", :require => nil, :platforms => :ruby_19
8
+ gem "shoulda"
9
+ gem "rake"
10
+ gem "mocha"
11
+ gem "nokogiri"
12
+ end
data/LICENSE ADDED
@@ -0,0 +1,19 @@
1
+ Copyright (c) 2010 OneLogin, LLC
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining a copy
4
+ of this software and associated documentation files (the "Software"), to deal
5
+ in the Software without restriction, including without limitation the rights
6
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7
+ copies of the Software, and to permit persons to whom the Software is
8
+ furnished to do so, subject to the following conditions:
9
+
10
+ The above copyright notice and this permission notice shall be included in
11
+ all copies or substantial portions of the Software.
12
+
13
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
19
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,124 @@
1
+ # Ruby SAML FEDERA
2
+
3
+ The Ruby SAML Federa library is a fork of Ruby SAML and it is used for implementing the client side of a SAML authorization, initialization and confirmation requests
4
+ with FedERa's identity providers.
5
+
6
+
7
+ ## The initialization phase
8
+
9
+ This is the first request you will get from the identity provider. It will hit your application at a specific URL (that you've announced as being your SAML initialization point). The response to this initialization, is a redirect back to the identity provider, which can look something like this:
10
+
11
+ ```ruby
12
+ def init
13
+ #your login with Federa method..
14
+ #create an instance of Federa::Saml::Authrequest
15
+ request = Federa::Saml::Authrequest.new(get_saml_settings)
16
+ auth_request = request.create
17
+ # Based on the IdP metadata, select the appropriate binding
18
+ # and return the action to perform to the controller
19
+ meta = Federa::Saml::Metadata.new(get_saml_settings)
20
+ signature = get_signature(auth_request.uuid,auth_request.request,"http://www.w3.org/2000/09/xmldsig#rsa-sha1")
21
+ redirect meta.create_sso_request( auth_request.request, { :RelayState => request.uuid,
22
+ :SigAlg => "http://www.w3.org/2000/09/xmldsig#rsa-sha1",
23
+ :Signature => signature
24
+ } )
25
+ end
26
+
27
+
28
+ def get_signature(relayState, request, sigAlg)
29
+ #url encode of relayState
30
+ relayState_encoded = escape(relayState)
31
+
32
+ #deflate and base64 of samlrequest
33
+ deflate_request_B64 = encode(deflate(request))
34
+
35
+ #url encode of samlrequest
36
+ deflate_request_B64_encoded = escape(deflate_request_B64)
37
+
38
+ #url encode of sigAlg
39
+ sigAlg_encoded = escape(sigAlg)
40
+
41
+ querystring="SAMLRequest=#{deflate_request_B64_encoded}&RelayState=#{relayState_encoded}&SigAlg=#{sigAlg_encoded}"
42
+ digest = OpenSSL::Digest::SHA1.new(querystring.strip)
43
+ pk = OpenSSL::PKey::RSA.new File.read(File.join("path.of.cert.pem"))
44
+ qssigned = pk.sign(digest,querystring.strip)
45
+ Base64.encode64(qssigned).gsub(/\n/, "")
46
+ end
47
+ ```
48
+
49
+
50
+ Once you've redirected back to the identity provider, it will ensure that the user has been authorized and redirect back to your application for final consumption, this is can look something like this (the authorize_success and authorize_failure methods are specific to your application):
51
+
52
+ ```ruby
53
+ def consume
54
+ #your assertion_consumer method...
55
+ saml_response = @request.params['SAMLResponse']
56
+ if !saml_response.nil?
57
+ #read the settings
58
+ settings = get_saml_settings
59
+ #create an instance of response
60
+ response = Federa::Saml::Response.new(saml_response)
61
+ response.settings = settings
62
+
63
+ #validation of response
64
+ if response.is_valid?
65
+ authorize_success(response.attributes)
66
+ else
67
+ authorize_failure(response.attributes)
68
+ end
69
+ end
70
+ end
71
+ ```
72
+
73
+ In the above there are a few assumptions in place, one being that the response.name_id is an email address. This is all handled with how you specify the settings that are in play via the saml_settings method. That could be implemented along the lines of this:
74
+
75
+ ```ruby
76
+ def get_saml_settings
77
+ settings = Federa::Saml::Settings.new
78
+ settings.assertion_consumer_service_url = ...String, url of your assertion consumer.
79
+ settings.issuer = ...String, host of your service provider or metadata url.
80
+ settings.sp_cert = ...String, path of your cert.pem.
81
+ settings.single_logout_service_url = ...String, url of idp logout service'.
82
+ settings.sp_name_qualifier = ...String, name qualifier of service processor (like your metadata url).
83
+ settings.idp_name_qualifier = ...String, name qualifier of identity provider (idp metadata).
84
+ settings.name_identifier_format = ...Array, format names ( ["urn:oasis:names:tc:SAML:2.0:nameid-format:persistent", "urn:oasis:names:tc:SAML:2.0:nameid-format:transient", "urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified"] ).
85
+ settings.destination_service_url = ...String, url of proxy for single sign on (in Idp).
86
+ settings.single_logout_destination = ...String, url of logout request.
87
+ settings.authn_context = ...Array, types of permissions allowed (["urn:oasis:names:tc:SAML:2.0:ac:classes:Smartcard", "urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport"]).
88
+ settings.requester_identificator = ...unique id of your service provider domain.
89
+ settings.skip_validation = ...Bool, skip validation of assertion or response (false).
90
+ settings.idp_sso_target_url = ...String, url of idp sso proxy ("https://federatest.lepida.it/gw/SSOProxy/SAML2").
91
+ settings.idp_metadata = ...String, url of idp metadata ("https://federatest.lepida.it/gw/metadata").
92
+ settings
93
+ end
94
+ ```
95
+
96
+
97
+
98
+ ## Service Provider Metadata
99
+
100
+ To form a trusted pair relationship with the IdP, the SP (you) need to provide metadata XML
101
+ to the IdP for various good reasons. (Caching, certificate lookups, relying party permissions, etc)
102
+
103
+ The class Onelogin::Saml::Metdata takes care of this by reading the Settings and returning XML. All
104
+ you have to do is add a controller to return the data, then give this URL to the IdP administrator.
105
+ The metdata will be polled by the IdP every few minutes, so updating your settings should propagate
106
+ to the IdP settings.
107
+
108
+ ```ruby
109
+ class SamlController < ApplicationController
110
+ # ... the rest of your controller definitions ...
111
+ def metadata
112
+ meta = Federa::Saml::Metadata.new
113
+ settings = get_saml_settings
114
+ render :xml => meta.generate(settings)
115
+ end
116
+ end
117
+ ```
118
+
119
+ ## Note on Patches/Pull Requests
120
+
121
+ * Fork the project.
122
+ * Make your feature addition or bug fix.
123
+ * Commit, do not mess with rakefile, version, or history. (if you want to have your own version, that is fine but bump version in a commit by itself I can ignore when I pull)
124
+ * Send me a pull request.
data/Rakefile ADDED
@@ -0,0 +1,41 @@
1
+ require 'rubygems'
2
+ require 'rake'
3
+
4
+ #not being used yet.
5
+ require 'rake/testtask'
6
+ Rake::TestTask.new(:test) do |test|
7
+ test.libs << 'lib' << 'test'
8
+ test.pattern = 'test/**/*_test.rb'
9
+ test.verbose = true
10
+ end
11
+
12
+ begin
13
+ require 'rcov/rcovtask'
14
+ Rcov::RcovTask.new do |test|
15
+ test.libs << 'test'
16
+ test.pattern = 'test/**/*_test.rb'
17
+ test.verbose = true
18
+ end
19
+ rescue LoadError
20
+ task :rcov do
21
+ abort "RCov is not available. In order to run rcov, you must: sudo gem install spicycode-rcov"
22
+ end
23
+ end
24
+
25
+ task :test
26
+
27
+ task :default => :test
28
+
29
+ # require 'rake/rdoctask'
30
+ # Rake::RDocTask.new do |rdoc|
31
+ # if File.exist?('VERSION')
32
+ # version = File.read('VERSION')
33
+ # else
34
+ # version = ""
35
+ # end
36
+
37
+ # rdoc.rdoc_dir = 'rdoc'
38
+ # rdoc.title = "ruby-saml #{version}"
39
+ # rdoc.rdoc_files.include('README*')
40
+ # rdoc.rdoc_files.include('lib/**/*.rb')
41
+ #end
@@ -0,0 +1,181 @@
1
+ require "base64"
2
+ require "uuid"
3
+ require "zlib"
4
+ require "cgi"
5
+ require "rexml/document"
6
+ require "rexml/xpath"
7
+ require "rubygems"
8
+ require "addressable/uri"
9
+
10
+ module Federa::Saml
11
+ include REXML
12
+ class Authrequest
13
+ # a few symbols for SAML class names
14
+ HTTP_POST = "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST"
15
+ HTTP_GET = "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect"
16
+
17
+ attr_accessor :uuid, :request
18
+
19
+ def initialize( settings )
20
+ @settings = settings
21
+ @request_params = Hash.new
22
+ end
23
+
24
+ def create(params = {})
25
+ uuid = "_" + UUID.new.generate
26
+ self.uuid = uuid
27
+ time = Time.now.utc.strftime("%Y-%m-%dT%H:%M:%SZ")
28
+ # Create AuthnRequest root element using REXML
29
+ request_doc = REXML::Document.new
30
+ request_doc.context[:attribute_quote] = :quote
31
+ root = request_doc.add_element "saml2p:AuthnRequest", { "xmlns:saml2p" => "urn:oasis:names:tc:SAML:2.0:protocol" }
32
+ root.attributes['ID'] = uuid
33
+ root.attributes['IssueInstant'] = time
34
+ root.attributes['Version'] = "2.0"
35
+ root.attributes['ProtocolBinding'] = "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST"
36
+ #root.attributes['AttributeConsumingServiceIndex'] = "2"
37
+ root.attributes['ForceAuthn'] = "false"
38
+ root.attributes['IsPassive'] = "false"
39
+
40
+ # Conditionally defined elements based on settings
41
+ if @settings.assertion_consumer_service_url != nil
42
+ root.attributes["AssertionConsumerServiceURL"] = @settings.assertion_consumer_service_url
43
+ end
44
+
45
+ if @settings.destination_service_url != nil
46
+ root.attributes["Destination"] = @settings.destination_service_url
47
+ end
48
+
49
+ if @settings.issuer != nil
50
+ issuer = root.add_element "saml2:Issuer", { "xmlns:saml2" => "urn:oasis:names:tc:SAML:2.0:assertion" }
51
+ issuer.text = @settings.issuer
52
+ end
53
+ if @settings.name_identifier_format != nil
54
+ root.add_element "saml2p:NameIDPolicy", {
55
+ # Might want to make AllowCreate a setting?
56
+ "AllowCreate" => "true",
57
+ "Format" => @settings.name_identifier_format[1],
58
+ "SPNameQualifier" => @settings.sp_name_qualifier
59
+ }
60
+ end
61
+
62
+ # BUG fix here -- if an authn_context is defined, add the tags with an "exact"
63
+ # match required for authentication to succeed. If this is not defined,
64
+ # the IdP will choose default rules for authentication. (Shibboleth IdP)
65
+ if @settings.authn_context != nil
66
+ requested_context = root.add_element "saml2p:RequestedAuthnContext", {
67
+ "Comparison" => "exact"
68
+ }
69
+ context_class = []
70
+ @settings.authn_context.each_with_index{ |context, index|
71
+ context_class[index] = requested_context.add_element "saml2:AuthnContextClassRef", {
72
+ "xmlns:saml2" => "urn:oasis:names:tc:SAML:2.0:assertion"
73
+ }
74
+ context_class[index].text = context
75
+ }
76
+
77
+ end
78
+
79
+ if @settings.requester_identificator != nil
80
+ requester_identificator = root.add_element "saml2p:Scoping", {
81
+ "ProxyCount" => "1"
82
+ }
83
+ identificators = []
84
+ @settings.requester_identificator.each_with_index{ |requester, index|
85
+ identificators[index] = requester_identificator.add_element "saml2p:RequesterID"
86
+ identificators[index].text = requester
87
+ }
88
+
89
+ end
90
+
91
+ request_doc << REXML::XMLDecl.new(version='1.0', encoding='UTF-8')
92
+ ret = ""
93
+ # pretty print the XML so IdP administrators can easily see what the SP supports
94
+ request_doc.write(ret, 1)
95
+
96
+ @request = ""
97
+ request_doc.write(@request)
98
+
99
+ #Logging.debug "Created AuthnRequest: #{@request}"
100
+
101
+ return self
102
+
103
+ end
104
+
105
+ # get the IdP metadata, and select the appropriate SSO binding
106
+ # that we can support. Currently this is HTTP-Redirect and HTTP-POST
107
+ # but more could be added in the future
108
+ def binding_select
109
+ # first check if we're still using the old hard coded method for
110
+ # backwards compatability
111
+ if @settings.idp_metadata == nil && @settings.idp_sso_target_url != nil
112
+ @URL = @settings.idp_sso_target_url
113
+ return "GET", content_get
114
+ end
115
+ # grab the metadata
116
+ metadata = Metadata::new
117
+ meta_doc = metadata.get_idp_metadata(@settings)
118
+
119
+ # first try POST
120
+ sso_element = REXML::XPath.first(meta_doc,
121
+ "/EntityDescriptor/IDPSSODescriptor/SingleSignOnService[@Binding='#{HTTP_POST}']")
122
+ if sso_element
123
+ @URL = sso_element.attributes["Location"]
124
+ #Logging.debug "binding_select: POST to #{@URL}"
125
+ return "POST", content_post
126
+ end
127
+
128
+ # next try GET
129
+ sso_element = REXML::XPath.first(meta_doc,
130
+ "/EntityDescriptor/IDPSSODescriptor/SingleSignOnService[@Binding='#{HTTP_GET}']")
131
+ if sso_element
132
+ @URL = sso_element.attributes["Location"]
133
+ Logging.debug "binding_select: GET from #{@URL}"
134
+ return "GET", content_get
135
+ end
136
+ # other types we might want to add in the future: SOAP, Artifact
137
+ end
138
+
139
+ # construct the the parameter list on the URL and return
140
+ def content_get
141
+ # compress GET requests to try and stay under that 8KB request limit
142
+ deflated_request = Zlib::Deflate.deflate(@request, 9)[2..-5]
143
+ # strict_encode64() isn't available? sub out the newlines
144
+ @request_params["SAMLRequest"] = Base64.encode64(deflated_request).gsub(/\n/, "")
145
+
146
+ Logging.debug "SAMLRequest=#{@request_params["SAMLRequest"]}"
147
+ uri = Addressable::URI.parse(@URL)
148
+ if uri.query_values == nil
149
+ uri.query_values = @request_params
150
+ else
151
+ # solution to stevenwilkin's parameter merge
152
+ uri.query_values = @request_params.merge(uri.query_values)
153
+ end
154
+ url = uri.to_s
155
+ #Logging.debug "Sending to URL #{url}"
156
+ return url
157
+ end
158
+ # construct an HTML form (POST) and return the content
159
+ def content_post
160
+ # POST requests seem to bomb out when they're deflated
161
+ # and they probably don't need to be compressed anyway
162
+ @request_params["SAMLRequest"] = Base64.encode64(@request).gsub(/\n/, "")
163
+
164
+ #Logging.debug "SAMLRequest=#{@request_params["SAMLRequest"]}"
165
+ # kind of a cheesy method of building an HTML, form since we can't rely on Rails too much,
166
+ # and REXML doesn't work well with quote characters
167
+ str = "<html><body onLoad=\"document.getElementById('form').submit();\">\n"
168
+ str += "<form id='form' name='form' method='POST' action=\"#{@URL}\">\n"
169
+ # we could change this in the future to associate a temp auth session ID
170
+ str += "<input name='RelayState' value='ruby-saml' type='hidden' />\n"
171
+ @request_params.each_pair do |key, value|
172
+ str += "<input name=\"#{key}\" value=\"#{value}\" type='hidden' />\n"
173
+ #str += "<input name=\"#{key}\" value=\"#{CGI.escape(value)}\" type='hidden' />\n"
174
+ end
175
+ str += "</form></body></html>\n"
176
+
177
+ #Logging.debug "Created form:\n#{str}"
178
+ return str
179
+ end
180
+ end
181
+ end
@@ -0,0 +1,34 @@
1
+ require "cgi"
2
+ require 'zlib'
3
+
4
+ module Federa
5
+ module Saml
6
+ module Coding
7
+ def decode(encoded)
8
+ Base64.decode64(encoded)
9
+ end
10
+
11
+ def encode(encoded)
12
+ Base64.encode64(encoded).gsub(/\n/, "")
13
+ end
14
+
15
+ def escape(unescaped)
16
+ CGI.escape(unescaped)
17
+ end
18
+
19
+ def unescape(escaped)
20
+ CGI.unescape(escaped)
21
+ end
22
+
23
+ def inflate(deflated)
24
+ zlib = Zlib::Inflate.new(-Zlib::MAX_WBITS)
25
+ zlib.inflate(deflated)
26
+ end
27
+
28
+ def deflate(inflated)
29
+ Zlib::Deflate.deflate(inflated, 9)[2..-5]
30
+ end
31
+
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,26 @@
1
+ # Simplistic log class when we're running in Rails
2
+ module Federa
3
+ module Saml
4
+ class Logging
5
+ def self.debug(message)
6
+ return if !!ENV["ruby-saml/testing"]
7
+
8
+ if defined? Rails
9
+ Rails.logger.debug message
10
+ else
11
+ puts message
12
+ end
13
+ end
14
+
15
+ def self.info(message)
16
+ return if !!ENV["ruby-saml/testing"]
17
+
18
+ if defined? Rails
19
+ Rails.logger.info message
20
+ else
21
+ puts message
22
+ end
23
+ end
24
+ end
25
+ end
26
+ end