ruby-saml 0.8.12

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.

Files changed (70) hide show
  1. checksums.yaml +7 -0
  2. data/.document +5 -0
  3. data/.gitignore +12 -0
  4. data/.travis.yml +11 -0
  5. data/Gemfile +37 -0
  6. data/LICENSE +19 -0
  7. data/README.md +160 -0
  8. data/Rakefile +27 -0
  9. data/changelog.md +24 -0
  10. data/lib/onelogin/ruby-saml/attributes.rb +147 -0
  11. data/lib/onelogin/ruby-saml/authrequest.rb +168 -0
  12. data/lib/onelogin/ruby-saml/logging.rb +26 -0
  13. data/lib/onelogin/ruby-saml/logoutrequest.rb +161 -0
  14. data/lib/onelogin/ruby-saml/logoutresponse.rb +153 -0
  15. data/lib/onelogin/ruby-saml/metadata.rb +66 -0
  16. data/lib/onelogin/ruby-saml/response.rb +426 -0
  17. data/lib/onelogin/ruby-saml/setting_error.rb +6 -0
  18. data/lib/onelogin/ruby-saml/settings.rb +166 -0
  19. data/lib/onelogin/ruby-saml/slo_logoutresponse.rb +158 -0
  20. data/lib/onelogin/ruby-saml/utils.rb +119 -0
  21. data/lib/onelogin/ruby-saml/validation_error.rb +7 -0
  22. data/lib/onelogin/ruby-saml/version.rb +5 -0
  23. data/lib/ruby-saml.rb +12 -0
  24. data/lib/schemas/saml20assertion_schema.xsd +283 -0
  25. data/lib/schemas/saml20protocol_schema.xsd +302 -0
  26. data/lib/schemas/xenc_schema.xsd +146 -0
  27. data/lib/schemas/xmldsig_schema.xsd +318 -0
  28. data/lib/xml_security.rb +292 -0
  29. data/ruby-saml.gemspec +28 -0
  30. data/test/certificates/certificate1 +12 -0
  31. data/test/certificates/r1_certificate2_base64 +1 -0
  32. data/test/certificates/ruby-saml.crt +14 -0
  33. data/test/certificates/ruby-saml.key +15 -0
  34. data/test/logoutrequest_test.rb +244 -0
  35. data/test/logoutresponse_test.rb +112 -0
  36. data/test/request_test.rb +229 -0
  37. data/test/response_test.rb +475 -0
  38. data/test/responses/adfs_response_sha1.xml +46 -0
  39. data/test/responses/adfs_response_sha256.xml +46 -0
  40. data/test/responses/adfs_response_sha384.xml +46 -0
  41. data/test/responses/adfs_response_sha512.xml +46 -0
  42. data/test/responses/encrypted_new_attack.xml.base64 +1 -0
  43. data/test/responses/logoutresponse_fixtures.rb +67 -0
  44. data/test/responses/no_signature_ns.xml +48 -0
  45. data/test/responses/open_saml_response.xml +56 -0
  46. data/test/responses/r1_response6.xml.base64 +1 -0
  47. data/test/responses/response1.xml.base64 +1 -0
  48. data/test/responses/response2.xml.base64 +79 -0
  49. data/test/responses/response3.xml.base64 +66 -0
  50. data/test/responses/response4.xml.base64 +93 -0
  51. data/test/responses/response5.xml.base64 +102 -0
  52. data/test/responses/response_eval.xml +7 -0
  53. data/test/responses/response_node_text_attack.xml.base64 +1 -0
  54. data/test/responses/response_with_ampersands.xml +139 -0
  55. data/test/responses/response_with_ampersands.xml.base64 +93 -0
  56. data/test/responses/response_with_concealed_signed_assertion.xml +51 -0
  57. data/test/responses/response_with_doubled_signed_assertion.xml +49 -0
  58. data/test/responses/response_with_multiple_attribute_statements.xml +72 -0
  59. data/test/responses/response_with_multiple_attribute_values.xml +67 -0
  60. data/test/responses/response_wrapped.xml.base64 +150 -0
  61. data/test/responses/simple_saml_php.xml +71 -0
  62. data/test/responses/starfield_response.xml.base64 +1 -0
  63. data/test/responses/valid_response.xml.base64 +1 -0
  64. data/test/responses/wrapped_response_2.xml.base64 +150 -0
  65. data/test/settings_test.rb +47 -0
  66. data/test/slo_logoutresponse_test.rb +226 -0
  67. data/test/test_helper.rb +155 -0
  68. data/test/utils_test.rb +41 -0
  69. data/test/xml_security_test.rb +158 -0
  70. metadata +178 -0
@@ -0,0 +1,66 @@
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 RubySaml
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
+ # Metadata request need not be signed (as we don't publish our cert)
21
+ "AuthnRequestsSigned" => false,
22
+ # However we would like assertions signed if idp_cert_fingerprint or idp_cert is set
23
+ "WantAssertionsSigned" => (!settings.idp_cert_fingerprint.nil? || !settings.idp_cert.nil?)
24
+ }
25
+ if settings.sp_entity_id != nil
26
+ root.attributes["entityID"] = settings.sp_entity_id
27
+ end
28
+ if settings.single_logout_service_url != nil
29
+ sp_sso.add_element "md:SingleLogoutService", {
30
+ # Add this as a setting to create different bindings?
31
+ "Binding" => "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect",
32
+ "Location" => settings.single_logout_service_url,
33
+ "ResponseLocation" => settings.single_logout_service_url,
34
+ "isDefault" => true,
35
+ "index" => 0
36
+ }
37
+ end
38
+ if settings.name_identifier_format != nil
39
+ name_id = sp_sso.add_element "md:NameIDFormat"
40
+ name_id.text = settings.name_identifier_format
41
+ end
42
+ if settings.assertion_consumer_service_url != nil
43
+ sp_sso.add_element "md:AssertionConsumerService", {
44
+ # Add this as a setting to create different bindings?
45
+ "Binding" => "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST",
46
+ "Location" => settings.assertion_consumer_service_url,
47
+ "isDefault" => true,
48
+ "index" => 0
49
+ }
50
+ end
51
+ # With OpenSSO, it might be required to also include
52
+ # <md:RoleDescriptor xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:query="urn:oasis:names:tc:SAML:metadata:ext:query" xsi:type="query:AttributeQueryDescriptorType" protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol"/>
53
+ # <md:XACMLAuthzDecisionQueryDescriptor WantAssertionsSigned="false" protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol"/>
54
+
55
+ meta_doc << REXML::XMLDecl.new
56
+ ret = ""
57
+ # pretty print the XML so IdP administrators can easily see what the SP supports
58
+ meta_doc.write(ret, 1)
59
+
60
+ Logging.debug "Generated metadata:\n#{ret}"
61
+
62
+ ret
63
+ end
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,426 @@
1
+ require "xml_security"
2
+ require "time"
3
+ require "nokogiri"
4
+ require 'onelogin/ruby-saml/attributes'
5
+
6
+ # Only supports SAML 2.0
7
+ module OneLogin
8
+ module RubySaml
9
+
10
+ class Response
11
+ ASSERTION = "urn:oasis:names:tc:SAML:2.0:assertion"
12
+ PROTOCOL = "urn:oasis:names:tc:SAML:2.0:protocol"
13
+ DSIG = "http://www.w3.org/2000/09/xmldsig#"
14
+
15
+ # TODO: This should probably be ctor initialized too... WDYT?
16
+ attr_accessor :settings
17
+
18
+ attr_reader :options
19
+ attr_reader :response
20
+ attr_reader :document
21
+
22
+ def initialize(response, options = {})
23
+ raise ArgumentError.new("Response cannot be nil") if response.nil?
24
+ @options = options
25
+ @response = (response =~ /^</) ? response : Base64.decode64(response)
26
+ @document = XMLSecurity::SignedDocument.new(@response)
27
+ end
28
+
29
+ def is_valid?
30
+ validate
31
+ end
32
+
33
+ def validate!
34
+ validate(false)
35
+ end
36
+
37
+ # The value of the user identifier as designated by the initialization request response
38
+ def name_id
39
+ @name_id ||= begin
40
+ node = xpath_first_from_signed_assertion('/a:Subject/a:NameID')
41
+ Utils.element_text(node)
42
+ end
43
+ end
44
+
45
+ alias nameid name_id
46
+
47
+ def sessionindex
48
+ @sessionindex ||= begin
49
+ node = xpath_first_from_signed_assertion('/a:AuthnStatement')
50
+ node.nil? ? nil : node.attributes['SessionIndex']
51
+ end
52
+ end
53
+
54
+ # Gets the Attributes from the AttributeStatement element.
55
+ #
56
+ # All attributes can be iterated over +attributes.each+ or returned as array by +attributes.all+
57
+ # For backwards compatibility ruby-saml returns by default only the first value for a given attribute with
58
+ # attributes['name']
59
+ # To get all of the attributes, use:
60
+ # attributes.multi('name')
61
+ # Or turn off the compatibility:
62
+ # OneLogin::RubySaml::Attributes.single_value_compatibility = false
63
+ # Now this will return an array:
64
+ # attributes['name']
65
+ #
66
+ # @return [Attributes] OneLogin::RubySaml::Attributes enumerable collection.
67
+ #
68
+ def attributes
69
+ @attr_statements ||= begin
70
+ attributes = Attributes.new
71
+
72
+ stmt_elements = xpath_from_signed_assertion('/a:AttributeStatement')
73
+ stmt_elements.each do |stmt_element|
74
+ stmt_element.elements.each do |attr_element|
75
+ name = attr_element.attributes["Name"]
76
+ values = attr_element.elements.collect{|e|
77
+ if (e.elements.nil? || e.elements.size == 0)
78
+ # SAMLCore requires that nil AttributeValues MUST contain xsi:nil XML attribute set to "true" or "1"
79
+ # otherwise the value is to be regarded as empty.
80
+ ["true", "1"].include?(e.attributes['xsi:nil']) ? nil : Utils.element_text(e)
81
+ # explicitly support saml2:NameID with saml2:NameQualifier if supplied in attributes
82
+ # this is useful for allowing eduPersonTargetedId to be passed as an opaque identifier to use to
83
+ # identify the subject in an SP rather than email or other less opaque attributes
84
+ # NameQualifier, if present is prefixed with a "/" to the value
85
+ else
86
+ REXML::XPath.match(e,'a:NameID', { "a" => ASSERTION }).collect{|n|
87
+ (n.attributes['NameQualifier'] ? n.attributes['NameQualifier'] +"/" : '') + Utils.element_text(n)
88
+ }
89
+ end
90
+ }
91
+
92
+ attributes.add(name, values.flatten)
93
+ end
94
+ end
95
+ attributes
96
+ end
97
+ end
98
+
99
+ # When this user session should expire at latest
100
+ def session_expires_at
101
+ @expires_at ||= begin
102
+ node = xpath_first_from_signed_assertion('/a:AuthnStatement')
103
+ parse_time(node, "SessionNotOnOrAfter")
104
+ end
105
+ end
106
+
107
+ # Checks the status of the response for a "Success" code
108
+ def success?
109
+ @status_code ||= begin
110
+ node = REXML::XPath.first(document, "/p:Response/p:Status/p:StatusCode", { "p" => PROTOCOL, "a" => ASSERTION })
111
+ node.attributes["Value"] == "urn:oasis:names:tc:SAML:2.0:status:Success"
112
+ end
113
+ end
114
+
115
+ # Conditions (if any) for the assertion to run
116
+ def conditions
117
+ @conditions ||= xpath_first_from_signed_assertion('/a:Conditions')
118
+ end
119
+
120
+ def not_before
121
+ @not_before ||= parse_time(conditions, "NotBefore")
122
+ end
123
+
124
+ def not_on_or_after
125
+ @not_on_or_after ||= parse_time(conditions, "NotOnOrAfter")
126
+ end
127
+
128
+ def issuer
129
+ @issuer ||= begin
130
+ node = REXML::XPath.first(document, "/p:Response/a:Issuer", { "p" => PROTOCOL, "a" => ASSERTION })
131
+ node ||= xpath_first_from_signed_assertion('/a:Issuer')
132
+ Utils.element_text(node)
133
+ end
134
+ end
135
+
136
+ # @return [Array] The Audience elements from the Contitions of the SAML Response.
137
+ #
138
+ def audiences
139
+ @audiences ||= begin
140
+ nodes = xpath_from_signed_assertion('/a:Conditions/a:AudienceRestriction/a:Audience')
141
+ nodes.map { |node| Utils.element_text(node) }.reject(&:empty?)
142
+ end
143
+ end
144
+
145
+ private
146
+
147
+ def validation_error(message)
148
+ raise ValidationError.new(message)
149
+ end
150
+
151
+ def validate(soft = true)
152
+ validate_structure(soft) &&
153
+ validate_success_status(soft) &&
154
+ validate_num_assertion &&
155
+ validate_signed_elements(soft) &&
156
+ validate_response_state(soft) &&
157
+ validate_conditions(soft) &&
158
+ validate_audience(soft) &&
159
+ document.validate_document(get_fingerprint, soft) &&
160
+ success?
161
+ end
162
+
163
+ # Validates that the SAML Response only contains a single Assertion (encrypted or not).
164
+ # @return [Boolean] True if the SAML Response contains one unique Assertion, otherwise False
165
+ #
166
+ def validate_num_assertion(soft = true)
167
+ assertions = REXML::XPath.match(
168
+ document,
169
+ "//a:Assertion",
170
+ { "a" => ASSERTION }
171
+ )
172
+ encrypted_assertions = REXML::XPath.match(
173
+ document,
174
+ "//a:EncryptedAssertion",
175
+ { "a" => ASSERTION }
176
+ )
177
+
178
+ unless assertions.size + encrypted_assertions.size == 1
179
+ return soft ? false : validation_error("SAML Response must contain 1 assertion")
180
+ end
181
+
182
+ true
183
+ end
184
+
185
+ # Validates the Signed elements
186
+ # @return [Boolean] True if there is 1 or 2 Elements signed in the SAML Response
187
+ # an are a Response or an Assertion Element, otherwise False if soft=True
188
+ #
189
+ def validate_signed_elements(soft)
190
+ signature_nodes = REXML::XPath.match(
191
+ document,
192
+ "//ds:Signature",
193
+ {"ds"=>DSIG}
194
+ )
195
+ signed_elements = []
196
+ verified_seis = []
197
+ verified_ids = []
198
+ signature_nodes.each do |signature_node|
199
+ signed_element = signature_node.parent.name
200
+ if signed_element != 'Response' && signed_element != 'Assertion'
201
+ return soft ? false : validation_error("Invalid Signature Element '#{signed_element}'. SAML Response rejected")
202
+ end
203
+
204
+ if signature_node.parent.attributes['ID'].nil?
205
+ return soft ? false : validation_error("Signed Element must contain an ID. SAML Response rejected")
206
+ end
207
+
208
+ id = signature_node.parent.attributes.get_attribute("ID").value
209
+ if verified_ids.include?(id)
210
+ return soft ? false : validation_error("Duplicated ID. SAML Response rejected")
211
+ end
212
+ verified_ids.push(id)
213
+
214
+ # Check that reference URI matches the parent ID and no duplicate References or IDs
215
+ ref = REXML::XPath.first(signature_node, ".//ds:Reference", {"ds"=>DSIG})
216
+ if ref
217
+ uri = ref.attributes.get_attribute("URI")
218
+ if uri && !uri.value.empty?
219
+ sei = uri.value[1..-1]
220
+
221
+ unless sei == id
222
+ return soft ? false : validation_error("Found an invalid Signed Element. SAML Response rejected")
223
+ end
224
+
225
+ if verified_seis.include?(sei)
226
+ return soft ? false : validation_error("Duplicated Reference URI. SAML Response rejected")
227
+ return append_error("Duplicated Reference URI. SAML Response rejected")
228
+ end
229
+
230
+ verified_seis.push(sei)
231
+ end
232
+ end
233
+
234
+ signed_elements << signed_element
235
+ end
236
+
237
+ unless signature_nodes.length < 3 && !signed_elements.empty?
238
+ return soft ? false : validation_error("Found an unexpected number of Signature Element. SAML Response rejected")
239
+ end
240
+
241
+ true
242
+ end
243
+
244
+ # Validates the Status of the SAML Response
245
+ # @return [Boolean] True if the SAML Response contains a Success code, otherwise False if soft == false
246
+ # @raise [ValidationError] if soft == false and validation fails
247
+ #
248
+ def validate_success_status(soft = true)
249
+ return true if success?
250
+
251
+ return false unless soft
252
+
253
+ error_msg = 'The status code of the Response was not Success'
254
+ status_error_msg = OneLogin::RubySaml::Utils.status_error_msg(error_msg, status_code, status_message)
255
+ return validation_error(status_error_msg)
256
+ end
257
+
258
+ # Checks if the Status has the "Success" code
259
+ # @return [Boolean] True if the StatusCode is Sucess
260
+ #
261
+ def success?
262
+ status_code == "urn:oasis:names:tc:SAML:2.0:status:Success"
263
+ end
264
+
265
+ # @return [String] StatusCode value from a SAML Response.
266
+ #
267
+ def status_code
268
+ @status_code ||= begin
269
+ nodes = REXML::XPath.match(
270
+ document,
271
+ "/p:Response/p:Status/p:StatusCode",
272
+ { "p" => PROTOCOL }
273
+ )
274
+ if nodes.size == 1
275
+ node = nodes[0]
276
+ code = node.attributes["Value"] if node && node.attributes
277
+
278
+ unless code == "urn:oasis:names:tc:SAML:2.0:status:Success"
279
+ nodes = REXML::XPath.match(
280
+ document,
281
+ "/p:Response/p:Status/p:StatusCode/p:StatusCode",
282
+ { "p" => PROTOCOL }
283
+ )
284
+ statuses = nodes.collect do |inner_node|
285
+ inner_node.attributes["Value"]
286
+ end
287
+ extra_code = statuses.join(" | ")
288
+ if extra_code
289
+ code = "#{code} | #{extra_code}"
290
+ end
291
+ end
292
+ code
293
+ end
294
+ end
295
+ end
296
+
297
+ # @return [String] the StatusMessage value from a SAML Response.
298
+ #
299
+ def status_message
300
+ @status_message ||= begin
301
+ nodes = REXML::XPath.match(
302
+ document,
303
+ "/p:Response/p:Status/p:StatusMessage",
304
+ { "p" => PROTOCOL }
305
+ )
306
+ if nodes.size == 1
307
+ Utils.element_text(nodes.first)
308
+ end
309
+ end
310
+ end
311
+
312
+ def validate_structure(soft = true)
313
+ Dir.chdir(File.expand_path(File.join(File.dirname(__FILE__), '..', '..', 'schemas'))) do
314
+ @schema = Nokogiri::XML::Schema(IO.read('saml20protocol_schema.xsd'))
315
+ @xml = Nokogiri::XML(self.document.to_s)
316
+ end
317
+ if soft
318
+ @schema.validate(@xml).map{ return false }
319
+ else
320
+ @schema.validate(@xml).map{ |error| validation_error("#{error.message}\n\n#{@xml.to_s}") }
321
+ end
322
+ end
323
+
324
+ def validate_response_state(soft = true)
325
+ if response.empty?
326
+ return soft ? false : validation_error("Blank response")
327
+ end
328
+
329
+ if settings.nil?
330
+ return soft ? false : validation_error("No settings on response")
331
+ end
332
+
333
+ if settings.idp_cert_fingerprint.nil? && settings.idp_cert.nil?
334
+ return soft ? false : validation_error("No fingerprint or certificate on settings")
335
+ end
336
+
337
+ true
338
+ end
339
+
340
+ def xpath_first_from_signed_assertion(subelt=nil)
341
+ node = REXML::XPath.first(
342
+ document,
343
+ "/p:Response/a:Assertion[@ID=$id]#{subelt}",
344
+ { "p" => PROTOCOL, "a" => ASSERTION },
345
+ { 'id' => document.signed_element_id }
346
+ )
347
+ node ||= REXML::XPath.first(
348
+ document,
349
+ "/p:Response[@ID=$id]/a:Assertion#{subelt}",
350
+ { "p" => PROTOCOL, "a" => ASSERTION },
351
+ { 'id' => document.signed_element_id }
352
+ )
353
+ node
354
+ end
355
+
356
+ # Extracts all the appearances that matchs the subelt (pattern)
357
+ # Search on any Assertion that is signed, or has a Response parent signed
358
+ # @param subelt [String] The XPath pattern
359
+ # @return [Array of REXML::Element] Return all matches
360
+ #
361
+ def xpath_from_signed_assertion(subelt=nil)
362
+ node = REXML::XPath.match(
363
+ document,
364
+ "/p:Response/a:Assertion[@ID=$id]#{subelt}",
365
+ { "p" => PROTOCOL, "a" => ASSERTION },
366
+ { 'id' => document.signed_element_id }
367
+ )
368
+ node.concat( REXML::XPath.match(
369
+ document,
370
+ "/p:Response[@ID=$id]/a:Assertion#{subelt}",
371
+ { "p" => PROTOCOL, "a" => ASSERTION },
372
+ { 'id' => document.signed_element_id }
373
+ ))
374
+ end
375
+
376
+ def get_fingerprint
377
+ if settings.idp_cert
378
+ cert = OpenSSL::X509::Certificate.new(settings.idp_cert)
379
+ Digest::SHA1.hexdigest(cert.to_der).upcase.scan(/../).join(":")
380
+ else
381
+ settings.idp_cert_fingerprint
382
+ end
383
+ end
384
+
385
+ def validate_conditions(soft = true)
386
+ return true if conditions.nil?
387
+ return true if options[:skip_conditions]
388
+
389
+ now = Time.now.utc
390
+
391
+ if not_before && (now + (options[:allowed_clock_drift] || 0)) < not_before
392
+ return soft ? false : validation_error("Current time is earlier than NotBefore condition")
393
+ end
394
+
395
+ if not_on_or_after && now >= not_on_or_after
396
+ return soft ? false : validation_error("Current time is on or after NotOnOrAfter condition")
397
+ end
398
+
399
+ true
400
+ end
401
+
402
+ def parse_time(node, attribute)
403
+ if node && node.attributes[attribute]
404
+ Time.parse(node.attributes[attribute])
405
+ end
406
+ end
407
+
408
+ # Validates the Audience, (If the Audience match the Service Provider EntityID)
409
+ # If fails, the error is added to the errors array
410
+ # @return [Boolean] True if there is an Audience Element that match the Service Provider EntityID, otherwise False if soft=True
411
+ # @raise [ValidationError] if soft == false and validation fails
412
+ #
413
+ def validate_audience(soft = true)
414
+ return true if audiences.empty? || settings.sp_entity_id.nil? || settings.sp_entity_id.empty?
415
+
416
+ unless audiences.include? settings.sp_entity_id
417
+ s = audiences.count > 1 ? 's' : '';
418
+ error_msg = "Invalid Audience#{s}. The audience#{s} #{audiences.join(',')}, did not match the expected audience #{settings.sp_entity_id}"
419
+ return soft ? false : validation_error(error_msg)
420
+ end
421
+
422
+ true
423
+ end
424
+ end
425
+ end
426
+ end