ruby-saml 0.9.4 → 1.0.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.
Files changed (101) hide show
  1. checksums.yaml +4 -4
  2. data/.gitignore +1 -0
  3. data/LICENSE +1 -1
  4. data/README.md +71 -15
  5. data/changelog.md +15 -6
  6. data/lib/onelogin/ruby-saml.rb +1 -0
  7. data/lib/onelogin/ruby-saml/attribute_service.rb +25 -2
  8. data/lib/onelogin/ruby-saml/attributes.rb +42 -23
  9. data/lib/onelogin/ruby-saml/authrequest.rb +33 -8
  10. data/lib/onelogin/ruby-saml/http_error.rb +7 -0
  11. data/lib/onelogin/ruby-saml/idp_metadata_parser.rb +65 -10
  12. data/lib/onelogin/ruby-saml/logging.rb +14 -10
  13. data/lib/onelogin/ruby-saml/logoutrequest.rb +39 -14
  14. data/lib/onelogin/ruby-saml/logoutresponse.rb +166 -39
  15. data/lib/onelogin/ruby-saml/metadata.rb +40 -23
  16. data/lib/onelogin/ruby-saml/response.rb +562 -88
  17. data/lib/onelogin/ruby-saml/saml_message.rb +80 -14
  18. data/lib/onelogin/ruby-saml/settings.rb +62 -23
  19. data/lib/onelogin/ruby-saml/slo_logoutrequest.rb +210 -20
  20. data/lib/onelogin/ruby-saml/slo_logoutresponse.rb +44 -13
  21. data/lib/onelogin/ruby-saml/utils.rb +163 -40
  22. data/lib/onelogin/ruby-saml/version.rb +1 -1
  23. data/lib/schemas/saml-schema-metadata-2.0.xsd +0 -2
  24. data/lib/xml_security.rb +87 -29
  25. data/ruby-saml.gemspec +1 -0
  26. data/test/certificates/{r1_certificate2_base64 → certificate_without_head_foot} +0 -0
  27. data/test/certificates/formatted_certificate +14 -0
  28. data/test/certificates/formatted_private_key +12 -0
  29. data/test/certificates/formatted_rsa_private_key +12 -0
  30. data/test/certificates/invalid_certificate1 +1 -0
  31. data/test/certificates/invalid_certificate2 +1 -0
  32. data/test/certificates/invalid_certificate3 +12 -0
  33. data/test/certificates/invalid_private_key1 +1 -0
  34. data/test/certificates/invalid_private_key2 +1 -0
  35. data/test/certificates/invalid_private_key3 +10 -0
  36. data/test/certificates/invalid_rsa_private_key1 +1 -0
  37. data/test/certificates/invalid_rsa_private_key2 +1 -0
  38. data/test/certificates/invalid_rsa_private_key3 +10 -0
  39. data/test/idp_metadata_parser_test.rb +41 -4
  40. data/test/logging_test.rb +62 -0
  41. data/test/logout_requests/invalid_slo_request.xml +6 -0
  42. data/test/{responses → logout_requests}/slo_request.xml +0 -0
  43. data/test/logout_requests/slo_request.xml.base64 +1 -0
  44. data/test/logout_requests/slo_request_deflated.xml.base64 +1 -0
  45. data/test/logout_requests/slo_request_with_session_index.xml +5 -0
  46. data/test/{responses → logout_responses}/logoutresponse_fixtures.rb +6 -6
  47. data/test/logoutrequest_test.rb +79 -52
  48. data/test/logoutresponse_test.rb +206 -59
  49. data/test/metadata_test.rb +77 -7
  50. data/test/request_test.rb +80 -65
  51. data/test/response_test.rb +862 -189
  52. data/test/responses/attackxee.xml +13 -0
  53. data/test/responses/invalids/invalid_audience.xml.base64 +1 -0
  54. data/test/responses/invalids/invalid_issuer_assertion.xml.base64 +1 -0
  55. data/test/responses/invalids/invalid_issuer_message.xml.base64 +1 -0
  56. data/test/responses/invalids/invalid_signature_position.xml.base64 +1 -0
  57. data/test/responses/invalids/invalid_subjectconfirmation_inresponse.xml.base64 +1 -0
  58. data/test/responses/invalids/invalid_subjectconfirmation_nb.xml.base64 +1 -0
  59. data/test/responses/invalids/invalid_subjectconfirmation_noa.xml.base64 +1 -0
  60. data/test/responses/invalids/invalid_subjectconfirmation_recipient.xml.base64 +1 -0
  61. data/test/responses/invalids/multiple_assertions.xml.base64 +2 -0
  62. data/test/responses/invalids/multiple_signed.xml.base64 +1 -0
  63. data/test/responses/invalids/no_id.xml.base64 +1 -0
  64. data/test/responses/invalids/no_saml2.xml.base64 +1 -0
  65. data/test/responses/invalids/no_signature.xml.base64 +1 -0
  66. data/test/responses/invalids/no_status.xml.base64 +1 -0
  67. data/test/responses/invalids/no_status_code.xml.base64 +1 -0
  68. data/test/responses/invalids/no_subjectconfirmation_data.xml.base64 +1 -0
  69. data/test/responses/invalids/no_subjectconfirmation_method.xml.base64 +1 -0
  70. data/test/responses/invalids/response_encrypted_attrs.xml.base64 +1 -0
  71. data/test/responses/invalids/response_invalid_signed_element.xml.base64 +1 -0
  72. data/test/responses/invalids/status_code_responder.xml.base64 +1 -0
  73. data/test/responses/invalids/status_code_responer_and_msg.xml.base64 +1 -0
  74. data/test/responses/{response4.xml.base64 → response_assertion_wrapped.xml.base64} +0 -0
  75. data/test/responses/response_encrypted_nameid.xml.base64 +1 -0
  76. data/test/responses/response_unsigned_xml_base64 +1 -0
  77. data/test/responses/{response5.xml.base64 → response_with_saml2_namespace.xml.base64} +0 -0
  78. data/test/responses/{response3.xml.base64 → response_with_signed_assertion.xml.base64} +0 -0
  79. data/test/responses/{r1_response6.xml.base64 → response_with_signed_assertion_2.xml.base64} +0 -0
  80. data/test/responses/{response1.xml.base64 → response_with_undefined_recipient.xml.base64} +0 -0
  81. data/test/responses/{response2.xml.base64 → response_without_attributes.xml.base64} +0 -0
  82. data/test/responses/{wrapped_response_2.xml.base64 → response_wrapped.xml.base64} +0 -0
  83. data/test/responses/signed_message_encrypted_signed_assertion.xml.base64 +1 -0
  84. data/test/responses/signed_message_encrypted_unsigned_assertion.xml.base64 +1 -0
  85. data/test/responses/unsigned_message_aes128_encrypted_signed_assertion.xml.base64 +1 -0
  86. data/test/responses/unsigned_message_aes192_encrypted_signed_assertion.xml.base64 +1 -0
  87. data/test/responses/unsigned_message_aes256_encrypted_signed_assertion.xml.base64 +1 -0
  88. data/test/responses/unsigned_message_des192_encrypted_signed_assertion.xml.base64 +1 -0
  89. data/test/responses/unsigned_message_encrypted_assertion_without_saml_namespace.xml.base64 +1 -0
  90. data/test/responses/unsigned_message_encrypted_signed_assertion.xml.base64 +1 -0
  91. data/test/responses/unsigned_message_encrypted_unsigned_assertion.xml.base64 +1 -0
  92. data/test/responses/valid_response.xml.base64 +1 -0
  93. data/test/saml_message_test.rb +56 -0
  94. data/test/settings_test.rb +138 -1
  95. data/test/slo_logoutrequest_test.rb +239 -28
  96. data/test/slo_logoutresponse_test.rb +93 -71
  97. data/test/test_helper.rb +138 -31
  98. data/test/utils_test.rb +129 -25
  99. data/test/xml_security_test.rb +140 -71
  100. metadata +142 -25
  101. data/test/responses/response_node_text_attack.xml.base64 +0 -1
@@ -3,24 +3,53 @@ require "uuid"
3
3
 
4
4
  require "onelogin/ruby-saml/logging"
5
5
 
6
- # Class to return SP metadata based on the settings requested.
7
- # Return this XML in a controller, then give that URL to the the
8
- # IdP administrator. The IdP will poll the URL and your settings
9
- # will be updated automatically
6
+ # Only supports SAML 2.0
10
7
  module OneLogin
11
8
  module RubySaml
9
+
10
+ # SAML2 Metadata. XML Metadata Builder
11
+ #
12
12
  class Metadata
13
- def generate(settings, pretty_print=true)
13
+
14
+ # Return SP metadata based on the settings.
15
+ # @param settings [OneLogin::RubySaml::Settings|nil] Toolkit settings
16
+ # @param pretty_print [Boolean] Pretty print or not the response
17
+ # (No pretty print if you gonna validate the signature)
18
+ # @return [String] XML Metadata of the Service Provider
19
+ #
20
+ def generate(settings, pretty_print=false)
14
21
  meta_doc = XMLSecurity::Document.new
15
- root = meta_doc.add_element "md:EntityDescriptor", {
22
+ namespaces = {
16
23
  "xmlns:md" => "urn:oasis:names:tc:SAML:2.0:metadata"
17
24
  }
25
+ if settings.attribute_consuming_service.configured?
26
+ namespaces["xmlns:saml"] = "urn:oasis:names:tc:SAML:2.0:assertion"
27
+ end
28
+ root = meta_doc.add_element "md:EntityDescriptor", namespaces
18
29
  sp_sso = root.add_element "md:SPSSODescriptor", {
19
30
  "protocolSupportEnumeration" => "urn:oasis:names:tc:SAML:2.0:protocol",
20
31
  "AuthnRequestsSigned" => settings.security[:authn_requests_signed],
21
32
  # However we would like assertions signed if idp_cert_fingerprint or idp_cert is set
22
33
  "WantAssertionsSigned" => !!(settings.idp_cert_fingerprint || settings.idp_cert)
23
34
  }
35
+
36
+ # Add KeyDescriptor if messages will be signed / encrypted
37
+ cert = settings.get_sp_cert
38
+ if cert
39
+ cert_text = Base64.encode64(cert.to_der).gsub("\n", '')
40
+ kd = sp_sso.add_element "md:KeyDescriptor", { "use" => "signing" }
41
+ ki = kd.add_element "ds:KeyInfo", {"xmlns:ds" => "http://www.w3.org/2000/09/xmldsig#"}
42
+ xd = ki.add_element "ds:X509Data"
43
+ xc = xd.add_element "ds:X509Certificate"
44
+ xc.text = cert_text
45
+
46
+ kd2 = sp_sso.add_element "md:KeyDescriptor", { "use" => "encryption" }
47
+ ki2 = kd2.add_element "ds:KeyInfo", {"xmlns:ds" => "http://www.w3.org/2000/09/xmldsig#"}
48
+ xd2 = ki2.add_element "ds:X509Data"
49
+ xc2 = xd2.add_element "ds:X509Certificate"
50
+ xc2.text = cert_text
51
+ end
52
+
24
53
  root.attributes["ID"] = "_" + UUID.new.generate
25
54
  if settings.issuer
26
55
  root.attributes["entityID"] = settings.issuer
@@ -29,14 +58,12 @@ module OneLogin
29
58
  sp_sso.add_element "md:SingleLogoutService", {
30
59
  "Binding" => settings.single_logout_service_binding,
31
60
  "Location" => settings.single_logout_service_url,
32
- "ResponseLocation" => settings.single_logout_service_url,
33
- "isDefault" => true,
34
- "index" => 0
61
+ "ResponseLocation" => settings.single_logout_service_url
35
62
  }
36
63
  end
37
64
  if settings.name_identifier_format
38
- name_id = sp_sso.add_element "md:NameIDFormat"
39
- name_id.text = settings.name_identifier_format
65
+ nameid = sp_sso.add_element "md:NameIDFormat"
66
+ nameid.text = settings.name_identifier_format
40
67
  end
41
68
  if settings.assertion_consumer_service_url
42
69
  sp_sso.add_element "md:AssertionConsumerService", {
@@ -47,16 +74,6 @@ module OneLogin
47
74
  }
48
75
  end
49
76
 
50
- # Add KeyDescriptor if messages will be signed
51
- cert = settings.get_sp_cert()
52
- if cert
53
- kd = sp_sso.add_element "md:KeyDescriptor", { "use" => "signing" }
54
- ki = kd.add_element "ds:KeyInfo", {"xmlns:ds" => "http://www.w3.org/2000/09/xmldsig#"}
55
- xd = ki.add_element "ds:X509Data"
56
- xc = xd.add_element "ds:X509Certificate"
57
- xc.text = Base64.encode64(cert.to_der).gsub("\n", '')
58
- end
59
-
60
77
  if settings.attribute_consuming_service.configured?
61
78
  sp_acs = sp_sso.add_element "md:AttributeConsumingService", {
62
79
  "isDefault" => "true",
@@ -73,7 +90,7 @@ module OneLogin
73
90
  "FriendlyName" => attribute[:friendly_name]
74
91
  }
75
92
  unless attribute[:attribute_value].nil?
76
- sp_attr_val = sp_req_attr.add_element "md:AttributeValue"
93
+ sp_attr_val = sp_req_attr.add_element "saml:AttributeValue"
77
94
  sp_attr_val.text = attribute[:attribute_value]
78
95
  end
79
96
  end
@@ -87,7 +104,7 @@ module OneLogin
87
104
 
88
105
  # embed signature
89
106
  if settings.security[:metadata_signed] && settings.private_key && settings.certificate
90
- private_key = settings.get_sp_key()
107
+ private_key = settings.get_sp_key
91
108
  meta_doc.sign_document(private_key, cert, settings.security[:signature_method], settings.security[:digest_method])
92
109
  end
93
110
 
@@ -8,47 +8,98 @@ require "nokogiri"
8
8
  module OneLogin
9
9
  module RubySaml
10
10
 
11
+ # SAML2 Authentication Response. SAML Response
12
+ #
11
13
  class Response < SamlMessage
12
14
  ASSERTION = "urn:oasis:names:tc:SAML:2.0:assertion"
13
15
  PROTOCOL = "urn:oasis:names:tc:SAML:2.0:protocol"
14
16
  DSIG = "http://www.w3.org/2000/09/xmldsig#"
17
+ XENC = "http://www.w3.org/2001/04/xmlenc#"
15
18
 
16
- # TODO: This should probably be ctor initialized too... WDYT?
19
+ # TODO: Settings should probably be initialized too... WDYT?
20
+
21
+ # OneLogin::RubySaml::Settings Toolkit settings
17
22
  attr_accessor :settings
23
+
24
+ # Array with the causes [Array of strings]
18
25
  attr_accessor :errors
19
26
 
20
- attr_reader :options
21
- attr_reader :response
22
27
  attr_reader :document
28
+ attr_reader :decrypted_document
29
+ attr_reader :response
30
+ attr_reader :options
31
+
32
+ attr_accessor :soft
23
33
 
34
+ # Constructs the SAML Response. A Response Object that is an extension of the SamlMessage class.
35
+ # @param response [String] A UUEncoded SAML response from the IdP.
36
+ # @param options [Hash] :settings to provide the OneLogin::RubySaml::Settings object
37
+ # Or some options for the response validation process like skip the conditions validation
38
+ # with the :skip_conditions, or allow a clock_drift when checking dates with :allowed_clock_drift
39
+ # or :matches_request_id that will validate that the response matches the ID of the request.
24
40
  def initialize(response, options = {})
25
41
  @errors = []
42
+
26
43
  raise ArgumentError.new("Response cannot be nil") if response.nil?
27
- @options = options
44
+ @options = options
45
+
46
+ @soft = true
47
+ if !options.empty? && !options[:settings].nil?
48
+ @settings = options[:settings]
49
+ if !options[:settings].soft.nil?
50
+ @soft = options[:settings].soft
51
+ end
52
+ end
53
+
28
54
  @response = decode_raw_saml(response)
29
55
  @document = XMLSecurity::SignedDocument.new(@response, @errors)
56
+
57
+ if assertion_encrypted?
58
+ @decrypted_document = generate_decrypted_document
59
+ end
30
60
  end
31
61
 
32
- def is_valid?
33
- validate
62
+ # Append the cause to the errors array, and based on the value of soft, return false or raise
63
+ # an exception
64
+ def append_error(error_msg)
65
+ @errors << error_msg
66
+ return soft ? false : validation_error(error_msg)
34
67
  end
35
68
 
36
- def validate!
37
- validate(false)
69
+ # Reset the errors array
70
+ def reset_errors!
71
+ @errors = []
38
72
  end
39
73
 
40
- def errors
41
- @errors
74
+ # Validates the SAML Response with the default values (soft = true)
75
+ # @return [Boolean] TRUE if the SAML Response is valid
76
+ #
77
+ def is_valid?
78
+ validate
42
79
  end
43
80
 
44
- # The value of the user identifier as designated by the initialization request response
81
+ # @return [String] the NameID provided by the SAML response from the IdP.
82
+ #
45
83
  def name_id
46
84
  @name_id ||= begin
47
- node = xpath_first_from_signed_assertion('/a:Subject/a:NameID')
48
- Utils.element_text(node)
85
+ encrypted_node = xpath_first_from_signed_assertion('/a:Subject/a:EncryptedID')
86
+ if encrypted_node
87
+ node = decrypt_nameid(encrypted_node)
88
+ else
89
+ node = xpath_first_from_signed_assertion('/a:Subject/a:NameID')
90
+ end
91
+ node.nil? ? nil : node.text
49
92
  end
50
93
  end
51
94
 
95
+ alias_method :nameid, :name_id
96
+
97
+ # Gets the SessionIndex from the AuthnStatement.
98
+ # Could be used to be stored in the local session in order
99
+ # to be used in a future Logout Request that the SP could
100
+ # send to the IdP, to set what specific session must be deleted
101
+ # @return [String] SessionIndex Value
102
+ #
52
103
  def sessionindex
53
104
  @sessionindex ||= begin
54
105
  node = xpath_first_from_signed_assertion('/a:AuthnStatement')
@@ -56,9 +107,9 @@ module OneLogin
56
107
  end
57
108
  end
58
109
 
59
- # Returns OneLogin::RubySaml::Attributes enumerable collection.
60
- # All attributes can be iterated over +attributes.each+ or returned as array by +attributes.all+
110
+ # Gets the Attributes from the AttributeStatement element.
61
111
  #
112
+ # All attributes can be iterated over +attributes.each+ or returned as array by +attributes.all+
62
113
  # For backwards compatibility ruby-saml returns by default only the first value for a given attribute with
63
114
  # attributes['name']
64
115
  # To get all of the attributes, use:
@@ -67,6 +118,9 @@ module OneLogin
67
118
  # OneLogin::RubySaml::Attributes.single_value_compatibility = false
68
119
  # Now this will return an array:
69
120
  # attributes['name']
121
+ #
122
+ # @return [Attributes] OneLogin::RubySaml::Attributes enumerable collection.
123
+ #
70
124
  def attributes
71
125
  @attr_statements ||= begin
72
126
  attributes = Attributes.new
@@ -79,7 +133,7 @@ module OneLogin
79
133
  values = attr_element.elements.collect{|e|
80
134
  # SAMLCore requires that nil AttributeValues MUST contain xsi:nil XML attribute set to "true" or "1"
81
135
  # otherwise the value is to be regarded as empty.
82
- ["true", "1"].include?(e.attributes['xsi:nil']) ? nil : Utils.element_text(e)
136
+ ["true", "1"].include?(e.attributes['xsi:nil']) ? nil : e.text.to_s
83
137
  }
84
138
 
85
139
  attributes.add(name, values)
@@ -89,155 +143,575 @@ module OneLogin
89
143
  end
90
144
  end
91
145
 
92
- # When this user session should expire at latest
146
+ # Gets the SessionNotOnOrAfter from the AuthnStatement.
147
+ # Could be used to set the local session expiration (expire at latest)
148
+ # @return [String] The SessionNotOnOrAfter value
149
+ #
93
150
  def session_expires_at
94
151
  @expires_at ||= begin
95
152
  node = xpath_first_from_signed_assertion('/a:AuthnStatement')
96
- parse_time(node, "SessionNotOnOrAfter")
153
+ node.nil? ? nil : parse_time(node, "SessionNotOnOrAfter")
97
154
  end
98
155
  end
99
156
 
100
- # Checks the status of the response for a "Success" code
157
+ # Checks if the Status has the "Success" code
158
+ # @return [Boolean] True if the StatusCode is Sucess
159
+ #
101
160
  def success?
161
+ status_code == "urn:oasis:names:tc:SAML:2.0:status:Success"
162
+ end
163
+
164
+ # @return [String] StatusCode value from a SAML Response.
165
+ #
166
+ def status_code
102
167
  @status_code ||= begin
103
- node = REXML::XPath.first(document, "/p:Response/p:Status/p:StatusCode", { "p" => PROTOCOL, "a" => ASSERTION })
104
- node.attributes["Value"] == "urn:oasis:names:tc:SAML:2.0:status:Success"
168
+ node = REXML::XPath.first(
169
+ document,
170
+ "/p:Response/p:Status/p:StatusCode",
171
+ { "p" => PROTOCOL, "a" => ASSERTION }
172
+ )
173
+ node.attributes["Value"] if node && node.attributes
105
174
  end
106
175
  end
107
176
 
177
+ # @return [String] the StatusMessage value from a SAML Response.
178
+ #
108
179
  def status_message
109
180
  @status_message ||= begin
110
- node = REXML::XPath.first(document, "/p:Response/p:Status/p:StatusMessage", { "p" => PROTOCOL, "a" => ASSERTION })
111
- Utils.element_text(node)
181
+ node = REXML::XPath.first(
182
+ document,
183
+ "/p:Response/p:Status/p:StatusMessage",
184
+ { "p" => PROTOCOL, "a" => ASSERTION }
185
+ )
186
+ node.text if node
112
187
  end
113
188
  end
114
189
 
115
- # Conditions (if any) for the assertion to run
190
+ # Gets the Condition Element of the SAML Response if exists.
191
+ # (returns the first node that matches the supplied xpath)
192
+ # @return [REXML::Element] Conditions Element if exists
193
+ #
116
194
  def conditions
117
195
  @conditions ||= xpath_first_from_signed_assertion('/a:Conditions')
118
196
  end
119
197
 
198
+ # Gets the NotBefore Condition Element value.
199
+ # @return [Time] The NotBefore value in Time format
200
+ #
120
201
  def not_before
121
202
  @not_before ||= parse_time(conditions, "NotBefore")
122
203
  end
123
204
 
205
+ # Gets the NotOnOrAfter Condition Element value.
206
+ # @return [Time] The NotOnOrAfter value in Time format
207
+ #
124
208
  def not_on_or_after
125
209
  @not_on_or_after ||= parse_time(conditions, "NotOnOrAfter")
126
210
  end
127
211
 
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)
212
+ # Gets the Issuers (from Response and Assertion).
213
+ # (returns the first node that matches the supplied xpath from the Response and from the Assertion)
214
+ # @return [Array] Array with the Issuers (REXML::Element)
215
+ #
216
+ def issuers
217
+ @issuers ||= begin
218
+ issuers = []
219
+ nodes = REXML::XPath.match(
220
+ document,
221
+ "/p:Response/a:Issuer",
222
+ { "p" => PROTOCOL, "a" => ASSERTION }
223
+ )
224
+ nodes += xpath_from_signed_assertion("/a:Issuer")
225
+ nodes.each do |node|
226
+ issuers << node.text if node.text
227
+ end
228
+ issuers.uniq
229
+ end
230
+ end
231
+
232
+ # @return [String|nil] The InResponseTo attribute from the SAML Response.
233
+ #
234
+ def in_response_to
235
+ @in_response_to ||= begin
236
+ node = REXML::XPath.first(
237
+ document,
238
+ "/p:Response",
239
+ { "p" => PROTOCOL }
240
+ )
241
+ node.nil? ? nil : node.attributes['InResponseTo']
242
+ end
243
+ end
244
+
245
+ # @return [Array] The Audience elements from the Contitions of the SAML Response.
246
+ #
247
+ def audiences
248
+ @audiences ||= begin
249
+ audiences = []
250
+ nodes = xpath_from_signed_assertion('/a:Conditions/a:AudienceRestriction/a:Audience')
251
+ nodes.each do |node|
252
+ if node && node.text
253
+ audiences << node.text
254
+ end
255
+ end
256
+ audiences
133
257
  end
134
258
  end
135
259
 
260
+ # returns the allowed clock drift on timing validation
261
+ # @return [Integer]
262
+ def allowed_clock_drift
263
+ return options[:allowed_clock_drift] || 0
264
+ end
265
+
136
266
  private
137
267
 
138
- def validate(soft = true)
139
- valid_saml?(document, soft) &&
140
- validate_response_state(soft) &&
141
- validate_conditions(soft) &&
142
- validate_issuer(soft) &&
143
- document.validate_document(get_fingerprint, soft, :fingerprint_alg => settings.idp_cert_fingerprint_algorithm) &&
144
- validate_success_status(soft)
268
+ # Validates the SAML Response (calls several validation methods)
269
+ # @return [Boolean] True if the SAML Response is valid, otherwise False if soft=True
270
+ # @raise [ValidationError] if soft == false and validation fails
271
+ #
272
+ def validate
273
+ reset_errors!
274
+
275
+ validate_response_state &&
276
+ validate_version &&
277
+ validate_id &&
278
+ validate_success_status &&
279
+ validate_num_assertion &&
280
+ validate_no_encrypted_attributes &&
281
+ validate_signed_elements &&
282
+ validate_structure &&
283
+ validate_in_response_to &&
284
+ validate_conditions &&
285
+ validate_audience &&
286
+ validate_issuer &&
287
+ validate_session_expiration &&
288
+ validate_subject_confirmation &&
289
+ validate_signature
145
290
  end
146
291
 
147
- def validate_success_status(soft = true)
148
- if success?
149
- true
150
- else
151
- soft ? false : validation_error(status_message)
292
+
293
+ # Validates the Status of the SAML Response
294
+ # @return [Boolean] True if the SAML Response contains a Success code, otherwise False if soft == false
295
+ # @raise [ValidationError] if soft == false and validation fails
296
+ #
297
+ def validate_success_status
298
+ return true if success?
299
+
300
+ error_msg = 'The status code of the Response was not Success'
301
+ status_error_msg = OneLogin::RubySaml::Utils.status_error_msg(error_msg, status_code, status_message)
302
+ append_error(status_error_msg)
303
+ end
304
+
305
+ # Validates the SAML Response against the specified schema.
306
+ # @return [Boolean] True if the XML is valid, otherwise False if soft=True
307
+ # @raise [ValidationError] if soft == false and validation fails
308
+ #
309
+ def validate_structure
310
+ unless valid_saml?(document, soft)
311
+ return append_error("Invalid SAML Response. Not match the saml-schema-protocol-2.0.xsd")
152
312
  end
313
+
314
+ true
153
315
  end
154
316
 
155
- def validate_structure(soft = true)
156
- xml = Nokogiri::XML(self.document.to_s)
317
+ # Validates that the SAML Response provided in the initialization is not empty,
318
+ # also check that the setting and the IdP cert were also provided
319
+ # @param soft [Boolean] soft Enable or Disable the soft mode (In order to raise exceptions when the response is invalid or not)
320
+ # @return [Boolean] True if the required info is found, otherwise False if soft=True
321
+ # @raise [ValidationError] if soft == false and validation fails
322
+ #
323
+ def validate_response_state(soft = true)
324
+ return append_error("Blank response") if response.nil? || response.empty?
157
325
 
158
- SamlMessage.schema.validate(xml).map do |error|
159
- if soft
160
- @errors << "Schema validation failed"
161
- break false
162
- else
163
- error_message = [error.message, xml.to_s].join("\n\n")
326
+ return append_error("No settings on response") if settings.nil?
327
+
328
+ if settings.idp_cert_fingerprint.nil? && settings.idp_cert.nil?
329
+ return append_error("No fingerprint or certificate on settings")
330
+ end
331
+
332
+ true
333
+ end
334
+
335
+ # Validates that the SAML Response contains an ID
336
+ # If fails, the error is added to the errors array.
337
+ # @return [Boolean] True if the SAML Response contains an ID, otherwise returns False
338
+ #
339
+ def validate_id
340
+ unless id(document)
341
+ return append_error("Missing ID attribute on SAML Response")
342
+ end
343
+
344
+ true
345
+ end
346
+
347
+ # Validates the SAML version (2.0)
348
+ # If fails, the error is added to the errors array.
349
+ # @return [Boolean] True if the SAML Response is 2.0, otherwise returns False
350
+ #
351
+ def validate_version
352
+ unless version(document) == "2.0"
353
+ return append_error("Unsupported SAML version")
354
+ end
355
+
356
+ true
357
+ end
358
+
359
+ # Validates that the SAML Response only contains a single Assertion (encrypted or not).
360
+ # If fails, the error is added to the errors array.
361
+ # @return [Boolean] True if the SAML Response contains one unique Assertion, otherwise False
362
+ #
363
+ def validate_num_assertion
364
+ assertions = REXML::XPath.match(
365
+ document,
366
+ "//a:Assertion",
367
+ { "a" => ASSERTION }
368
+ )
369
+ encrypted_assertions = REXML::XPath.match(
370
+ document,
371
+ "//a:EncryptedAssertion",
372
+ { "a" => ASSERTION }
373
+ )
374
+
375
+ unless assertions.size + encrypted_assertions.size == 1
376
+ return append_error("SAML Response must contain 1 assertion")
377
+ end
378
+
379
+ true
380
+ end
381
+
382
+ # Validates that there are not EncryptedAttribute (not supported)
383
+ # If fails, the error is added to the errors array
384
+ # @return [Boolean] True if there are no EncryptedAttribute elements, otherwise False if soft=True
385
+ # @raise [ValidationError] if soft == false and validation fails
386
+ #
387
+ def validate_no_encrypted_attributes
388
+ nodes = xpath_from_signed_assertion("/a:AttributeStatement/a:EncryptedAttribute")
389
+ if nodes && nodes.length > 0
390
+ return append_error("There is an EncryptedAttribute in the Response and this SP not support them")
391
+ end
392
+
393
+ true
394
+ end
164
395
 
165
- @errors << error_message
166
- validation_error(error_message)
396
+
397
+ # Validates the Signed elements
398
+ # If fails, the error is added to the errors array
399
+ # @return [Boolean] True if there is 1 or 2 Elements signed in the SAML Response
400
+ # an are a Response or an Assertion Element, otherwise False if soft=True
401
+ #
402
+ def validate_signed_elements
403
+ signature_nodes = REXML::XPath.match(
404
+ decrypted_document.nil? ? document : decrypted_document,
405
+ "//ds:Signature",
406
+ {"ds"=>DSIG}
407
+ )
408
+ signed_elements = []
409
+ signature_nodes.each do |signature_node|
410
+ signed_element = signature_node.parent.name
411
+ if signed_element != 'Response' && signed_element != 'Assertion'
412
+ return append_error("Found an unexpected Signature Element. SAML Response rejected")
167
413
  end
414
+ signed_elements << signed_element
168
415
  end
416
+
417
+ unless signature_nodes.length < 3 && !signed_elements.empty?
418
+ return append_error("Found an unexpected number of Signature Element. SAML Response rejected")
419
+ end
420
+
421
+ true
169
422
  end
170
423
 
171
- def validate_response_state(soft = true)
172
- if response.empty?
173
- return soft ? false : validation_error("Blank response")
424
+ # Validates if the provided request_id match the inResponseTo value.
425
+ # If fails, the error is added to the errors array
426
+ # @return [Boolean] True if there is no request_id or it match, otherwise False if soft=True
427
+ # @raise [ValidationError] if soft == false and validation fails
428
+ #
429
+ def validate_in_response_to
430
+ return true unless options.has_key? :matches_request_id
431
+ return true if options[:matches_request_id].nil? || options[:matches_request_id].empty?
432
+ return true unless options[:matches_request_id] != in_response_to
433
+
434
+ error_msg = "The InResponseTo of the Response: #{in_response_to}, does not match the ID of the AuthNRequest sent by the SP: #{options[:matches_request_id]}"
435
+ append_error(error_msg)
436
+ end
437
+
438
+ # Validates the Audience, (If the Audience match the Service Provider EntityID)
439
+ # If fails, the error is added to the errors array
440
+ # @return [Boolean] True if there is an Audience Element that match the Service Provider EntityID, otherwise False if soft=True
441
+ # @raise [ValidationError] if soft == false and validation fails
442
+ #
443
+ def validate_audience
444
+ return true if audiences.empty? || settings.issuer.nil? || settings.issuer.empty?
445
+
446
+ unless audiences.include? settings.issuer
447
+ error_msg = "#{settings.issuer} is not a valid audience for this Response"
448
+ return append_error(error_msg)
174
449
  end
175
450
 
176
- if settings.nil?
177
- return soft ? false : validation_error("No settings on response")
451
+ true
452
+ end
453
+
454
+ # Validates the Conditions. (If the response was initialized with the :skip_conditions option, this validation is skipped,
455
+ # If the response was initialized with the :allowed_clock_drift option, the timing validations are relaxed by the allowed_clock_drift value)
456
+ # @return [Boolean] True if satisfies the conditions, otherwise False if soft=True
457
+ # @raise [ValidationError] if soft == false and validation fails
458
+ #
459
+ def validate_conditions
460
+ return true if conditions.nil?
461
+ return true if options[:skip_conditions]
462
+
463
+ now = Time.now.utc
464
+
465
+ if not_before && (now + allowed_clock_drift) < not_before
466
+ error_msg = "Current time is earlier than NotBefore condition #{(now + allowed_clock_drift)} < #{not_before})"
467
+ return append_error(error_msg)
178
468
  end
179
469
 
180
- if settings.idp_cert_fingerprint.nil? && settings.idp_cert.nil?
181
- return soft ? false : validation_error("No fingerprint or certificate on settings")
470
+ if not_on_or_after && now >= (not_on_or_after + allowed_clock_drift)
471
+ error_msg = "Current time is on or after NotOnOrAfter condition (#{now} >= #{not_on_or_after + allowed_clock_drift})"
472
+ return append_error(error_msg)
473
+ end
474
+
475
+ true
476
+ end
477
+
478
+ # Validates the Issuer (Of the SAML Response and the SAML Assertion)
479
+ # @param soft [Boolean] soft Enable or Disable the soft mode (In order to raise exceptions when the response is invalid or not)
480
+ # @return [Boolean] True if the Issuer matchs the IdP entityId, otherwise False if soft=True
481
+ # @raise [ValidationError] if soft == false and validation fails
482
+ #
483
+ def validate_issuer
484
+ return true if settings.idp_entity_id.nil?
485
+
486
+ issuers.each do |issuer|
487
+ unless URI.parse(issuer) == URI.parse(settings.idp_entity_id)
488
+ error_msg = "Doesn't match the issuer, expected: <#{settings.idp_entity_id}>, but was: <#{issuer}>"
489
+ return append_error(error_msg)
490
+ end
491
+ end
492
+
493
+ true
494
+ end
495
+
496
+ # Validates that the Session haven't expired (If the response was initialized with the :allowed_clock_drift option,
497
+ # this time validation is relaxed by the allowed_clock_drift value)
498
+ # If fails, the error is added to the errors array
499
+ # @param soft [Boolean] soft Enable or Disable the soft mode (In order to raise exceptions when the response is invalid or not)
500
+ # @return [Boolean] True if the SessionNotOnOrAfter of the AttributeStatement is valid, otherwise (when expired) False if soft=True
501
+ # @raise [ValidationError] if soft == false and validation fails
502
+ #
503
+ def validate_session_expiration(soft = true)
504
+ return true if session_expires_at.nil?
505
+
506
+ now = Time.now.utc
507
+ unless session_expires_at > (now + allowed_clock_drift)
508
+ error_msg = "The attributes have expired, based on the SessionNotOnOrAfter of the AttributeStatement of this Response"
509
+ return append_error(error_msg)
510
+ end
511
+
512
+ true
513
+ end
514
+
515
+ # Validates if exists valid SubjectConfirmation (If the response was initialized with the :allowed_clock_drift option,
516
+ # timimg validation are relaxed by the allowed_clock_drift value)
517
+ # If fails, the error is added to the errors array
518
+ # @return [Boolean] True if exists a valid SubjectConfirmation, otherwise False if soft=True
519
+ # @raise [ValidationError] if soft == false and validation fails
520
+ #
521
+ def validate_subject_confirmation
522
+ valid_subject_confirmation = false
523
+
524
+ subject_confirmation_nodes = xpath_from_signed_assertion('/a:Subject/a:SubjectConfirmation')
525
+
526
+ now = Time.now.utc
527
+ subject_confirmation_nodes.each do |subject_confirmation|
528
+ if subject_confirmation.attributes.include? "Method" and subject_confirmation.attributes['Method'] != 'urn:oasis:names:tc:SAML:2.0:cm:bearer'
529
+ next
530
+ end
531
+
532
+ confirmation_data_node = REXML::XPath.first(
533
+ subject_confirmation,
534
+ 'a:SubjectConfirmationData',
535
+ { "a" => ASSERTION }
536
+ )
537
+
538
+ next unless confirmation_data_node
539
+
540
+ attrs = confirmation_data_node.attributes
541
+ next if (attrs.include? "InResponseTo" and attrs['InResponseTo'] != in_response_to) ||
542
+ (attrs.include? "NotOnOrAfter" and (parse_time(confirmation_data_node, "NotOnOrAfter") + allowed_clock_drift) <= now) ||
543
+ (attrs.include? "NotBefore" and parse_time(confirmation_data_node, "NotBefore") > (now + allowed_clock_drift))
544
+
545
+ valid_subject_confirmation = true
546
+ break
547
+ end
548
+
549
+ if !valid_subject_confirmation
550
+ error_msg = "A valid SubjectConfirmation was not found on this Response"
551
+ return append_error(error_msg)
182
552
  end
183
553
 
184
554
  true
185
555
  end
186
556
 
557
+ # Validates the Signature
558
+ # @return [Boolean] True if not contains a Signature or if the Signature is valid, otherwise False if soft=True
559
+ # @raise [ValidationError] if soft == false and validation fails
560
+ #
561
+ def validate_signature
562
+ fingerprint = settings.get_fingerprint
563
+
564
+ # If the response contains the signature, and the assertion was encrypted, validate the original SAML Response
565
+ # otherwise, review if the decrypted assertion contains a signature
566
+ response_signed = REXML::XPath.first(
567
+ document,
568
+ "/p:Response[@ID=$id]",
569
+ { "p" => PROTOCOL, "ds" => DSIG },
570
+ { 'id' => document.signed_element_id }
571
+ )
572
+ doc = (response_signed || decrypted_document.nil?) ? document : decrypted_document
573
+
574
+ unless fingerprint && doc.validate_document(fingerprint, :fingerprint_alg => settings.idp_cert_fingerprint_algorithm)
575
+ error_msg = "Invalid Signature on SAML Response"
576
+ return append_error(error_msg)
577
+ end
578
+
579
+ true
580
+ end
581
+
582
+ # Extracts the first appearance that matchs the subelt (pattern)
583
+ # Search on any Assertion that is signed, or has a Response parent signed
584
+ # @param subelt [String] The XPath pattern
585
+ # @return [REXML::Element | nil] If any matches, return the Element
586
+ #
187
587
  def xpath_first_from_signed_assertion(subelt=nil)
588
+ doc = decrypted_document.nil? ? document : decrypted_document
188
589
  node = REXML::XPath.first(
189
- document,
590
+ doc,
190
591
  "/p:Response/a:Assertion[@ID=$id]#{subelt}",
191
592
  { "p" => PROTOCOL, "a" => ASSERTION },
192
- { 'id' => document.signed_element_id }
593
+ { 'id' => doc.signed_element_id }
193
594
  )
194
595
  node ||= REXML::XPath.first(
195
- document,
596
+ doc,
196
597
  "/p:Response[@ID=$id]/a:Assertion#{subelt}",
197
598
  { "p" => PROTOCOL, "a" => ASSERTION },
198
- { 'id' => document.signed_element_id }
599
+ { 'id' => doc.signed_element_id }
199
600
  )
200
601
  node
201
602
  end
202
603
 
203
- def get_fingerprint
204
- if settings.idp_cert
205
- cert = OpenSSL::X509::Certificate.new(settings.idp_cert)
206
- fingerprint_alg = XMLSecurity::BaseDocument.new.algorithm(settings.idp_cert_fingerprint_algorithm).new
207
- fingerprint_alg.hexdigest(cert.to_der).upcase.scan(/../).join(":")
208
- else
209
- settings.idp_cert_fingerprint
210
- end
604
+ # Extracts all the appearances that matchs the subelt (pattern)
605
+ # Search on any Assertion that is signed, or has a Response parent signed
606
+ # @param subelt [String] The XPath pattern
607
+ # @return [Array of REXML::Element] Return all matches
608
+ #
609
+ def xpath_from_signed_assertion(subelt=nil)
610
+ doc = decrypted_document.nil? ? document : decrypted_document
611
+ node = REXML::XPath.match(
612
+ doc,
613
+ "/p:Response/a:Assertion[@ID=$id]#{subelt}",
614
+ { "p" => PROTOCOL, "a" => ASSERTION },
615
+ { 'id' => doc.signed_element_id }
616
+ )
617
+ node.concat( REXML::XPath.match(
618
+ doc,
619
+ "/p:Response[@ID=$id]/a:Assertion#{subelt}",
620
+ { "p" => PROTOCOL, "a" => ASSERTION },
621
+ { 'id' => doc.signed_element_id }
622
+ ))
211
623
  end
212
624
 
213
- def validate_conditions(soft = true)
214
- return true if conditions.nil?
215
- return true if options[:skip_conditions]
216
-
217
- now = Time.now.utc
218
-
219
- if not_before && (now + (options[:allowed_clock_drift] || 0)) < not_before
220
- @errors << "Current time is earlier than NotBefore condition #{(now + (options[:allowed_clock_drift] || 0))} < #{not_before})"
221
- return soft ? false : validation_error("Current time is earlier than NotBefore condition")
625
+ # Generates the decrypted_document
626
+ # @return [XMLSecurity::SignedDocument] The SAML Response with the assertion decrypted
627
+ #
628
+ def generate_decrypted_document
629
+ if settings.nil? || !settings.get_sp_key
630
+ validation_error('An EncryptedAssertion found and no SP private key found on the settings to decrypt it. Be sure you provided the :settings parameter at the initialize method')
222
631
  end
223
632
 
224
- if not_on_or_after && now >= not_on_or_after
225
- @errors << "Current time is on or after NotOnOrAfter condition (#{now} >= #{not_on_or_after})"
226
- return soft ? false : validation_error("Current time is on or after NotOnOrAfter condition")
633
+ # Marshal at Ruby 1.8.7 throw an Exception
634
+ if RUBY_VERSION < "1.9"
635
+ document_copy = XMLSecurity::SignedDocument.new(response, errors)
636
+ else
637
+ document_copy = Marshal.load(Marshal.dump(document))
227
638
  end
228
639
 
229
- true
640
+ decrypt_assertion_from_document(document_copy)
230
641
  end
231
642
 
232
- def validate_issuer(soft = true)
233
- return true if settings.idp_entity_id.nil?
643
+ # Obtains a SAML Response with the EncryptedAssertion element decrypted
644
+ # @param document_copy [XMLSecurity::SignedDocument] A copy of the original SAML Response with the encrypted assertion
645
+ # @return [XMLSecurity::SignedDocument] The SAML Response with the assertion decrypted
646
+ #
647
+ def decrypt_assertion_from_document(document_copy)
648
+ response_node = REXML::XPath.first(
649
+ document_copy,
650
+ "/p:Response/",
651
+ { "p" => PROTOCOL }
652
+ )
653
+ encrypted_assertion_node = REXML::XPath.first(
654
+ document_copy,
655
+ "(/p:Response/EncryptedAssertion/)|(/p:Response/a:EncryptedAssertion/)",
656
+ { "p" => PROTOCOL, "a" => ASSERTION }
657
+ )
658
+ response_node.add(decrypt_assertion(encrypted_assertion_node))
659
+ encrypted_assertion_node.remove
660
+ XMLSecurity::SignedDocument.new(response_node.to_s)
661
+ end
662
+
663
+ # Checks if the SAML Response contains or not an EncryptedAssertion element
664
+ # @return [Boolean] True if the SAML Response contains an EncryptedAssertion element
665
+ #
666
+ def assertion_encrypted?
667
+ ! REXML::XPath.first(
668
+ document,
669
+ "(/p:Response/EncryptedAssertion/)|(/p:Response/a:EncryptedAssertion/)",
670
+ { "p" => PROTOCOL, "a" => ASSERTION }
671
+ ).nil?
672
+ end
673
+
674
+ # Decrypts an EncryptedAssertion element
675
+ # @param encrypted_assertion_node [REXML::Element] The EncryptedAssertion element
676
+ # @return [REXML::Document] The decrypted EncryptedAssertion element
677
+ #
678
+ def decrypt_assertion(encrypted_assertion_node)
679
+ decrypt_element(encrypted_assertion_node, /(.*<\/(saml2*:|)Assertion>)/m)
680
+ end
681
+
682
+ # Decrypts an EncryptedID element
683
+ # @param encryptedid_node [REXML::Element] The EncryptedID element
684
+ # @return [REXML::Document] The decrypted EncrypedtID element
685
+ #
686
+ def decrypt_nameid(encryptedid_node)
687
+ decrypt_element(encryptedid_node, /(.*<\/(saml2*:|)NameID>)/m)
688
+ end
234
689
 
235
- unless URI.parse(issuer) == URI.parse(settings.idp_entity_id)
236
- return soft ? false : validation_error("Doesn't match the issuer, expected: <#{settings.idp_entity_id}>, but was: <#{issuer}>")
690
+ # Decrypt an element
691
+ # @param encryptedid_node [REXML::Element] The encrypted element
692
+ # @return [REXML::Document] The decrypted element
693
+ #
694
+ def decrypt_element(encrypt_node, rgrex)
695
+ if settings.nil? || !settings.get_sp_key
696
+ return validation_error('An ' + encrypt_node.name + ' found and no SP private key found on the settings to decrypt it')
237
697
  end
238
- true
698
+
699
+ elem_plaintext = OneLogin::RubySaml::Utils.decrypt_data(encrypt_node, settings.get_sp_key)
700
+ # If we get some problematic noise in the plaintext after decrypting.
701
+ # This quick regexp parse will grab only the Element and discard the noise.
702
+ elem_plaintext = elem_plaintext.match(rgrex)[0]
703
+ # To avoid namespace errors if saml namespace is not defined at assertion_plaintext
704
+ # create a parent node first with the saml namespace defined
705
+ elem_plaintext = '<node xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion">' + elem_plaintext + '</node>'
706
+ doc = REXML::Document.new(elem_plaintext)
707
+ doc.root[0]
239
708
  end
240
709
 
710
+ # Parse the attribute of a given node in Time format
711
+ # @param node [REXML:Element] The node
712
+ # @param attribute [String] The attribute name
713
+ # @return [Time|nil] The parsed value
714
+ #
241
715
  def parse_time(node, attribute)
242
716
  if node && node.attributes[attribute]
243
717
  Time.parse(node.attributes[attribute])