ruby-saml 0.8.12

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.

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