saml2 2.1.0 → 2.2.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 7882f9e6593924c18faed398409e973ca227162a990557156d7992a51c4f7cdb
4
- data.tar.gz: c862c3fd1c560068e59340ba4f19a3d810807b6c22a4c36c07fb0ef9a6711132
3
+ metadata.gz: af55cbf920b9c47ceee2e7f2fcad2878c042d1665f02b6933004ce7675cfb05f
4
+ data.tar.gz: 1f0f3e3cb1e009b7bf4d2af5e46a63d94d0d9d676d197554156cbc48c31786b1
5
5
  SHA512:
6
- metadata.gz: 89aa07641f0625f8f9fd781cb41a169349567531984c1fe2adc278ae0568687c4e4822be1a9c00e0c561116ffbc39d140abde34a1be439bd36b1a3869d34dcf5
7
- data.tar.gz: f07d6fbf9dc816d3223ae9cd1728b3232b469b61057ae7e6e14518efd939e3e8e4aff5c1e3b4a3c56584d7e5dbc9abc33dab180452a8b5d9b5ff7e34012c15e3
6
+ metadata.gz: 3d1cac3ac228678cf72214505d7eebec1af9c33b5f9464163119dd666549d82b4ab8fed696f739469ed740d51f1c69cef6ac858e9cfca5633633df396494e0bc
7
+ data.tar.gz: f23498a3981fd055ba7b791e33efe78d5bec213fd91866d7cc863c84133858459f224d1a391394d2c33d655cea8e335ef4cd06a7ecf76a407dc93025573e3ca4
@@ -31,6 +31,16 @@ module SAML2
31
31
  @conditions ||= Conditions.from_xml(xml.at_xpath('saml:Conditions', Namespaces::ALL))
32
32
  end
33
33
 
34
+ # @return [Array<AuthnStatement]
35
+ def authn_statements
36
+ statements.select { |s| s.is_a?(AuthnStatement) }
37
+ end
38
+
39
+ # @return [Array<AttributeStatement>]
40
+ def attribute_statements
41
+ statements.select { |s| s.is_a?(AttributeStatement) }
42
+ end
43
+
34
44
  # @return [Array<AuthnStatement, AttributeStatement>]
35
45
  def statements
36
46
  @statements ||= load_object_array(xml, 'saml:AuthnStatement|saml:AttributeStatement')
@@ -170,6 +170,38 @@ module SAML2
170
170
  end
171
171
  end
172
172
 
173
+ # Convert the {AttributeStatement} to a {Hash}
174
+ #
175
+ # Repeated attributes become an array.
176
+ #
177
+ # @param name optional [:name, :friendly_name]
178
+ # Which name field to use as keys to the hash
179
+ def to_h(name = :friendly_name)
180
+ result = {}
181
+ attributes.each do |attribute|
182
+ key = attribute.send(name)
183
+ # fall back to name on missing friendly name;
184
+ # no need for the opposite, because name is required
185
+ key ||= attribute.name if name == :friendly_name
186
+
187
+ prior_value = result[key]
188
+ result[key] = if prior_value
189
+ value = Array.wrap(prior_value)
190
+ # repeated key; convert to array
191
+ if attribute.value.is_a?(Array)
192
+ # both values are arrays; concatenate them
193
+ prior_value.concat(attribute.value)
194
+ else
195
+ value << attribute.value
196
+ end
197
+ value
198
+ else
199
+ attribute.value
200
+ end
201
+ end
202
+ result
203
+ end
204
+
173
205
  def build(builder)
174
206
  builder['saml'].AttributeStatement('xmlns:xs' => Namespaces::XS,
175
207
  'xmlns:xsi' => Namespaces::XSI) do |statement|
@@ -19,12 +19,12 @@ module SAML2
19
19
  ENTITLEMENT = 'urn:oid:1.3.6.1.4.1.5923.1.1.1.7'
20
20
  NICKNAME = 'urn:oid:1.3.6.1.4.1.5923.1.1.1.2'
21
21
  ORG_D_N = 'urn:oid:1.3.6.1.4.1.5923.1.1.1.3'
22
+ ORG_UNIT_D_N = 'urn:oid:1.3.6.1.4.1.5923.1.1.1.4'
22
23
  PRIMARY_AFFILIATION = 'urn:oid:1.3.6.1.4.1.5923.1.1.1.5'
23
24
  PRIMARY_ORG_UNIT_D_N = 'urn:oid:1.3.6.1.4.1.5923.1.1.1.8'
24
25
  PRINCIPAL_NAME = 'urn:oid:1.3.6.1.4.1.5923.1.1.1.6'
25
26
  SCOPED_AFFILIATION = 'urn:oid:1.3.6.1.4.1.5923.1.1.1.9'
26
27
  TARGETED_I_D = 'urn:oid:1.3.6.1.4.1.5923.1.1.1.10'
27
- UNIT_D_N = 'urn:oid:1.3.6.1.4.1.5923.1.1.1.4'
28
28
  end
29
29
  # http://www.ietf.org/rfc/rfc4519.txt
30
30
  UID = USERID = 'urn:oid:0.9.2342.19200300.100.1.1'
@@ -35,7 +35,8 @@ module SAML2
35
35
  # @param name_or_node [String, Nokogiri::XML::Element]
36
36
  def self.recognizes?(name_or_node)
37
37
  if name_or_node.is_a?(Nokogiri::XML::Element)
38
- !!name_or_node.at_xpath("@x500:Encoding", Namespaces::ALL)
38
+ !!name_or_node.at_xpath("@x500:Encoding", Namespaces::ALL) ||
39
+ name_or_node['NameFormat'] == NameFormats::URI && OIDS.include?(name_or_node['Name'])
39
40
  else
40
41
  FRIENDLY_NAMES.include?(name_or_node) || OIDS.include?(name_or_node)
41
42
  end
@@ -63,6 +64,14 @@ module SAML2
63
64
  super(name, value, friendly_name, NameFormats::URI)
64
65
  end
65
66
 
67
+ # (see Base.from_xml)
68
+ def from_xml(node)
69
+ super
70
+ # infer the friendly name if not provided
71
+ self.friendly_name ||= OIDS[name]
72
+ self
73
+ end
74
+
66
75
  # (see Base#build)
67
76
  def build(builder)
68
77
  super
@@ -85,6 +85,56 @@ module SAML2
85
85
  def build(builder)
86
86
  end
87
87
 
88
+ # Decrypt (in-place) encrypted portions of this object
89
+ #
90
+ # Either the +keys+ parameter, or a block that returns key(s), should be
91
+ # provided.
92
+ #
93
+ # @param keys optional [Array<OpenSSL::PKey, String>, OpenSSL::PKey, String, nil]
94
+ # @yield Optional block to fetch the necessary keys, given information
95
+ # contained in the encrypted elements of which certificates it was
96
+ # encrypted for.
97
+ # @yieldparam allowed_certs [Array<OpenSSL::X509::Certificate, Hash, String, nil>]
98
+ # An array of certificates describing who the node was encrypted for.
99
+ # Identified by an X.509 Certificate, a hash with +:issuer+ and +:serial+
100
+ # keys, or a string of SubjectName.
101
+ # @yieldreturn [Array<OpenSSL::PKey, String>, OpenSSL::PKey, String, nil]
102
+ # @return [Boolean] If any nodes were present.
103
+ def decrypt(keys = nil)
104
+ encrypted_nodes = self.encrypted_nodes
105
+ encrypted_nodes.each do |node|
106
+ this_nodes_keys = keys
107
+ if keys.nil?
108
+ allowed_certs = node.xpath("dsig:KeyInfo/xenc:EncryptedKey/dsig:KeyInfo/dsig:X509Data", SAML2::Namespaces::ALL).map do |x509data|
109
+ if (cert = x509data.at_xpath("dsig:X509Certificate", SAML2::Namespaces::ALL)&.content&.strip)
110
+ OpenSSL::X509::Certificate.new(Base64.decode64(cert))
111
+ elsif (issuer_serial = x509data.at_xpath("dsig:X509IssuerSerial", SAML2::Namespaces::ALL))
112
+ {
113
+ issuer: issuer_serial.at_xpath("dsig:X509IssuerName", SAML2::Namespaces::ALL).content.strip,
114
+ serial: issuer_serial.at_xpath("dsig:X509SerialNumber", SAML2::Namespaces::ALL).content.strip.to_i,
115
+ }
116
+ elsif (subject_name = x509data.at_xpath("dsig:X509SubjectName", SAML2::Namespaces::ALL)&.content&.strip)
117
+ subject_name
118
+ end
119
+ end
120
+ this_nodes_keys = yield allowed_certs
121
+ end
122
+ this_nodes_keys = Array(this_nodes_keys)
123
+ raise ArgumentError("no decryption key provided or found") if this_nodes_keys.empty?
124
+
125
+ old_node = node.parent
126
+ this_nodes_keys.each_with_index do |key, i|
127
+ begin
128
+ old_node.replace(node.decrypt_with(key: key))
129
+ rescue XMLSec::DecryptionError
130
+ # swallow errors on all but the last key
131
+ raise if i - 1 == this_nodes_keys.length
132
+ end
133
+ end
134
+ end
135
+ !encrypted_nodes.empty?
136
+ end
137
+
88
138
  def self.load_string_array(node, element)
89
139
  node.xpath(element, Namespaces::ALL).map do |element_node|
90
140
  element_node.content&.strip
@@ -119,6 +169,10 @@ module SAML2
119
169
  self.class.load_object_array(node, element, klass)
120
170
  end
121
171
 
172
+ def encrypted_nodes
173
+ xml.xpath('//xenc:EncryptedData', Namespaces::ALL)
174
+ end
175
+
122
176
  def self.split_qname(qname)
123
177
  if qname.include?(':')
124
178
  qname.split(':', 2)
@@ -22,38 +22,46 @@ module SAML2
22
22
  @not_before = Time.parse(node['NotBefore']) if node['NotBefore']
23
23
  @not_on_or_after = Time.parse(node['NotOnOrAfter']) if node['NotOnOrAfter']
24
24
 
25
- replace(node.children.map { |restriction| self.class.const_get(restriction.name, false).from_xml(restriction) })
25
+ replace(node.element_children.map do |restriction|
26
+ klass = if self.class.const_defined?(restriction.name, false)
27
+ self.class.const_get(restriction.name, false)
28
+ else
29
+ Condition
30
+ end
31
+ klass.from_xml(restriction)
32
+ end)
26
33
  end
27
34
 
28
35
  # Evaluate these conditions.
29
36
  #
30
- # @param now optional [Time]
37
+ # @param verification_time optional [Time]
31
38
  # @param options
32
39
  # Additional options to pass to specific {Condition}s
33
- # @return [Boolean, nil]
40
+ # @return [Array<>]
34
41
  # It's only valid if every sub-condition is completely valid.
35
42
  # If any sub-condition is invalid, the whole statement is invalid.
36
43
  # If the validity can't be determined due to an unsupported condition,
37
44
  # +nil+ will be returned (which is false-ish)
38
- def valid?(now: Time.now.utc, **options)
39
- options[:now] ||= now
40
- return false if not_before && now < not_before
41
- return false if not_on_or_after && now >= not_on_or_after
45
+ def validate(verification_time: Time.now.utc, **options)
46
+ options[:verification_time] ||= verification_time
47
+ errors = []
48
+ if not_before && verification_time < not_before
49
+ errors << "not_before #{not_before} is later than now (#{verification_time})"
50
+ end
51
+ if not_on_or_after && verification_time >= not_on_or_after
52
+ errors << "not_on_or_after #{not_on_or_after} is earlier than now (#{verification_time})"
53
+ end
42
54
 
43
- result = true
44
55
  each do |condition|
45
- this_result = condition.valid?(**options)
46
- case this_result
47
- when false
48
- return false
49
- when nil
50
- result = nil
51
- when true
52
- else
53
- raise "unknown validity of #{condition}"
54
- end
56
+ errors.concat(condition.validate(**options))
55
57
  end
56
- result
58
+ errors
59
+ end
60
+
61
+ # Use validate instead.
62
+ # @deprecated
63
+ def valid?(now: Time.now.utc, **options)
64
+ validate(verification_time: now, **options).empty?
57
65
  end
58
66
 
59
67
  # (see Base#build)
@@ -70,9 +78,13 @@ module SAML2
70
78
 
71
79
  # Any unknown condition
72
80
  class Condition < Base
73
- # @return [nil]
74
- def valid?(_)
75
- nil
81
+ # @return []
82
+ def validate(_)
83
+ ["unable to validate #{xml&.name || 'unrecognized'} condition"]
84
+ end
85
+
86
+ def valid?(*args)
87
+ validate(*args).empty?
76
88
  end
77
89
  end
78
90
 
@@ -96,8 +108,11 @@ module SAML2
96
108
  end
97
109
 
98
110
  # @param audience [String]
99
- def valid?(audience: nil, **_)
100
- Array.wrap(self.audience).include?(audience)
111
+ def validate(audience: nil, **_)
112
+ unless Array.wrap(self.audience).include?(audience)
113
+ return ["audience #{audience} not in allowed list of #{Array.wrap(self.audience).join(', ')}"]
114
+ end
115
+ []
101
116
  end
102
117
 
103
118
  # (see Base#build)
@@ -113,9 +128,9 @@ module SAML2
113
128
  class OneTimeUse < Condition
114
129
  # The caller will need to see if this condition exists, and validate it
115
130
  # using their own state store.
116
- # @return [true]
117
- def valid?(_)
118
- true
131
+ # @return [[]]
132
+ def validate(_)
133
+ []
119
134
  end
120
135
 
121
136
  # (see Base#build)
@@ -82,10 +82,11 @@ module SAML2
82
82
  end
83
83
  end
84
84
 
85
- def initialize
86
- super
85
+ # @param id [String] The Entity ID
86
+ def initialize(entity_id = nil)
87
+ super()
87
88
  @valid_until = nil
88
- @entity_id = nil
89
+ @entity_id = entity_id
89
90
  @roles = []
90
91
  @id = "_#{SecureRandom.uuid}"
91
92
  end
@@ -152,5 +153,24 @@ module SAML2
152
153
  super
153
154
  end
154
155
  end
156
+
157
+ # Validate a message is a valid response.
158
+ #
159
+ # @param message [Message]
160
+ # @param identity_provider [Entity]
161
+ def valid_response?(message,
162
+ identity_provider,
163
+ verification_time: Time.now.utc,
164
+ allow_expired_certificate: false)
165
+ unless message.is_a?(Response)
166
+ message.errors << "not a Response object"
167
+ return false
168
+ end
169
+
170
+ message.validate(service_provider: self,
171
+ identity_provider: identity_provider,
172
+ verification_time: verification_time,
173
+ allow_expired_certificate: allow_expired_certificate).empty?
174
+ end
155
175
  end
156
176
  end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'openssl'
4
+
3
5
  require 'saml2/base'
4
6
  require 'saml2/namespaces'
5
7
 
@@ -44,6 +44,7 @@ module SAML2
44
44
  class Message < Base
45
45
  include Signable
46
46
 
47
+ attr_reader :errors
47
48
  attr_writer :issuer, :destination
48
49
 
49
50
  class << self
@@ -93,6 +94,7 @@ module SAML2
93
94
 
94
95
  def initialize
95
96
  super
97
+ @errors = []
96
98
  @id = "_#{SecureRandom.uuid}"
97
99
  @issue_instant = Time.now.utc
98
100
  end
@@ -104,6 +106,11 @@ module SAML2
104
106
  @issue_instant = nil
105
107
  end
106
108
 
109
+ def validate
110
+ @errors = Schemas.protocol.validate(xml.document)
111
+ errors
112
+ end
113
+
107
114
  # If the XML is valid according to SAML XSDs.
108
115
  # @return [Boolean]
109
116
  def valid_schema?
@@ -115,9 +122,15 @@ module SAML2
115
122
  # (see Signable#validate_signature)
116
123
  # @param verification_time
117
124
  # Ignored. The message's {issue_instant} is always used.
118
- def validate_signature(fingerprint: nil, cert: nil, verification_time: issue_instant)
125
+ def validate_signature(fingerprint: nil,
126
+ cert: nil,
127
+ verification_time: issue_instant,
128
+ allow_expired_certificate: false)
119
129
  # verify the signature (certificate's validity) as of the time the message was generated
120
- super(fingerprint: fingerprint, cert: cert, verification_time: issue_instant)
130
+ super(fingerprint: fingerprint,
131
+ cert: cert,
132
+ verification_time: verification_time,
133
+ allow_expired_certificate: allow_expired_certificate)
121
134
  end
122
135
 
123
136
  # (see Signable#sign)
@@ -77,6 +77,150 @@ module SAML2
77
77
  remove_instance_variable(:@assertions)
78
78
  end
79
79
 
80
+ # Validates a response is well-formed, signed, and optionally decrypts it
81
+ #
82
+ # @param service_provider [Entity]
83
+ # The metadata object for the {ServiceProvider} receiving this
84
+ # {Response}. The first {ServiceProvider} in the {Entity} is used.
85
+ # @param identity_provider [Entity]
86
+ # The metadata object for the {IdentityProvider} the {Response} is
87
+ # being received from. The first {IdentityProvider} in the {Entity} is
88
+ # used.
89
+ # @param verification_time optional [DateTime]
90
+ # Validate timestamps (signing certificate validity, issued at, etc.) as of
91
+ # this point in time.
92
+ def validate(service_provider:,
93
+ identity_provider:,
94
+ verification_time: Time.now.utc,
95
+ allow_expired_certificate: false)
96
+ raise ArgumentError, "service_provider should be an Entity object" unless service_provider.is_a?(Entity)
97
+ raise ArgumentError, "service_provider should have at least one service_provider role" unless (sp = service_provider.service_providers.first)
98
+
99
+ # validate the schema
100
+ super()
101
+ return errors unless errors.empty?
102
+
103
+ # not finding the issuer is not exceptional
104
+ if identity_provider.nil?
105
+ errors << "could not find issuer of response"
106
+ return errors
107
+ end
108
+
109
+ # getting the wrong data type is exceptional, and we should raise an error
110
+ raise ArgumentError, "identity_provider should be an Entity object" unless identity_provider.is_a?(Entity)
111
+ raise ArgumentError, "identity_provider should have at least one identity_provider role" unless (idp = identity_provider.identity_providers.first)
112
+
113
+ unless identity_provider.entity_id == issuer.id
114
+ errors << "received unexpected message from '#{issuer.id}'; expected it to be from '#{identity_provider.entity_id}'"
115
+ return errors
116
+ end
117
+
118
+ certificates = idp.signing_keys.map(&:certificate)
119
+ if idp.fingerprints.empty? && certificates.empty?
120
+ errors << "could not find certificate to validate message"
121
+ return errors
122
+ end
123
+
124
+ if signed?
125
+ unless (signature_errors = validate_signature(fingerprint: idp.fingerprints,
126
+ cert: certificates,
127
+ allow_expired_certificate: allow_expired_certificate)).empty?
128
+ return errors.concat(signature_errors)
129
+ end
130
+ response_signed = true
131
+ end
132
+
133
+ find_decryption_key = ->(embedded_certificates) do
134
+ key = nil
135
+ embedded_certificates.each do |cert_info|
136
+ cert = case cert_info
137
+ when OpenSSL::X509::Certificate; cert_info
138
+ when Hash; sp.encryption_keys.map(&:certificate).find { |c| c.serial == cert_info[:serial] }
139
+ end
140
+ next unless cert
141
+ key = sp.private_keys.find { |k| cert.check_private_key(k) }
142
+ break if key
143
+ end
144
+ if !key
145
+ # couldn't figure out which key to use; just try them all
146
+ next sp.private_keys
147
+ end
148
+ key
149
+ end
150
+
151
+ unless sp.private_keys.empty?
152
+ begin
153
+ decypted_anything = decrypt(&find_decryption_key)
154
+ rescue XMLSec::DecryptionError
155
+ errors << "unable to decrypt response"
156
+ return errors
157
+ end
158
+
159
+ if decypted_anything
160
+ # have to re-validate the schema, since we just replaced content
161
+ super()
162
+ return errors unless errors.empty?
163
+ end
164
+ end
165
+
166
+ unless status.success?
167
+ errors << "response is not successful: #{status}"
168
+ return errors
169
+ end
170
+
171
+ assertion = assertions.first
172
+ unless assertion
173
+ errors << "no assertion found"
174
+ return errors
175
+ end
176
+
177
+ if assertion.signed?
178
+ unless (signature_errors = assertion.validate_signature(fingerprint: idp.fingerprints,
179
+ cert: certificates,
180
+ allow_expired_certificate: allow_expired_certificate)).empty?
181
+ return errors.concat(signature_errors)
182
+ end
183
+ assertion_signed = true
184
+ end
185
+
186
+ # only do our own issue instant validation if the assertion
187
+ # doesn't mandate any
188
+ unless assertion.conditions.not_on_or_after
189
+ if assertion.issue_instant + 5 * 60 < verification_time ||
190
+ assertion.issue_instant - 5 * 60 > verification_time
191
+ errors << "assertion not recently issued"
192
+ return errors
193
+ end
194
+ end
195
+
196
+ unless (condition_errors = assertion.conditions.validate(verification_time: verification_time,
197
+ audience: service_provider.entity_id)).empty?
198
+ return errors.concat(condition_errors)
199
+ end
200
+
201
+ if !response_signed && !assertion_signed
202
+ errors << "neither response nor assertion were signed"
203
+ return errors
204
+ end
205
+
206
+ unless sp.private_keys.empty?
207
+ begin
208
+ decypted_anything = assertion.decrypt(&find_decryption_key)
209
+ rescue XMLSec::DecryptionError
210
+ errors << "unable to decrypt assertion"
211
+ return errors
212
+ end
213
+
214
+ if decypted_anything
215
+ super()
216
+ return errors unless errors.empty?
217
+ end
218
+ end
219
+
220
+ # no error
221
+ errors
222
+ end
223
+
80
224
  # @return [Array<Assertion>]
81
225
  def assertions
82
226
  unless instance_variable_defined?(:@assertions)
@@ -88,10 +232,14 @@ module SAML2
88
232
  # (see Signable#sign)
89
233
  # Signs each assertion.
90
234
  def sign(x509_certificate, private_key, algorithm_name = :sha256)
91
- assertions.each { |assertion| assertion.sign(x509_certificate, private_key, algorithm_name) }
92
235
  # make sure we no longer pretty print this object
93
236
  @pretty = false
94
- nil
237
+
238
+ # if there are no assertions (encrypted?), just sign the response itself
239
+ return super if assertions.empty?
240
+
241
+ assertions.each { |assertion| assertion.sign(x509_certificate, private_key, algorithm_name) }
242
+ self
95
243
  end
96
244
 
97
245
  private