saml2 2.1.0 → 2.2.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.
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