Class: SAML2::Response

Inherits:
StatusResponse show all
Defined in:
lib/saml2/response.rb

Instance Attribute Summary

Attributes inherited from StatusResponse

#in_response_to, #status

Attributes inherited from Message

#destination, #errors, #issuer

Attributes inherited from Base

#xml

Class Method Summary collapse

Instance Method Summary collapse

Methods inherited from Message

from_xml, #id, #issue_instant, parse, #valid_schema?

Methods included from Signable

#signature, #signed?, #signing_key, #valid_signature?, #validate_signature

Methods inherited from Base

#decrypt, from_xml, #inspect, load_object_array, load_string_array, lookup_qname, #to_s, #to_xml

Constructor Details

#initializeResponse

Returns a new instance of Response.



69
70
71
72
# File 'lib/saml2/response.rb', line 69

def initialize
  super
  @assertions = []
end

Class Method Details

.initiate(service_provider, issuer, name_id, attributes = nil) ⇒ Response

Begin an IdP Initiated login

Parameters:

  • service_provider (ServiceProvider)
  • issuer (NameID)
  • name_id (NameID)

    The subject

  • attributes (defaults to: nil)

    optional [Hash<String => String>, Array<Attribute>]

Returns:



43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
# File 'lib/saml2/response.rb', line 43

def self.initiate(service_provider, issuer, name_id, attributes = nil)
  response = new
  response.issuer = issuer
  response.destination = service_provider.assertion_consumer_services.default.location if service_provider
  assertion = Assertion.new
  assertion.subject = Subject.new
  assertion.subject.name_id = name_id
  assertion.subject.confirmation = Subject::Confirmation.new
  assertion.subject.confirmation.method = Subject::Confirmation::Methods::BEARER
  assertion.subject.confirmation.not_on_or_after = Time.now.utc + 30
  assertion.subject.confirmation.recipient = response.destination if response.destination
  assertion.issuer = issuer
  assertion.conditions.not_before = Time.now.utc - 5
  assertion.conditions.not_on_or_after = Time.now.utc + 30
  authn_statement = AuthnStatement.new
  authn_statement.authn_instant = response.issue_instant
  authn_statement.authn_context_class_ref = AuthnStatement::Classes::UNSPECIFIED
  assertion.statements << authn_statement
  if attributes && service_provider.attribute_consuming_services.default
    statement = service_provider.attribute_consuming_services.default.create_statement(attributes)
    assertion.statements << statement if statement
  end
  response.assertions << assertion
  response
end

.respond_to(authn_request, issuer, name_id, attributes = nil) ⇒ Response

Respond to an AuthnRequest

AuthnRequest#resolve needs to have been previously called on the AuthnRequest.

Parameters:

  • authn_request (AuthnRequest)
  • issuer (NameID)
  • name_id (NameID)

    The Subject

  • attributes (defaults to: nil)

    optional [Hash<String => String>, Array<Attribute>]

Returns:



21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
# File 'lib/saml2/response.rb', line 21

def self.respond_to(authn_request, issuer, name_id, attributes = nil)
  response = initiate(nil, issuer, name_id)
  response.in_response_to = authn_request.id
  response.destination = authn_request.assertion_consumer_service.location
  confirmation = response.assertions.first.subject.confirmation
  confirmation.in_response_to = authn_request.id
  confirmation.recipient = response.destination
  if attributes && authn_request.attribute_consuming_service
    statement = authn_request.attribute_consuming_service.create_statement(attributes)
    response.assertions.first.statements << statement if statement
  end
  response.assertions.first.conditions << Conditions::AudienceRestriction.new(authn_request.issuer.id)

  response
end

Instance Method Details

#assertionsArray<Assertion>

Returns:



269
270
271
272
# File 'lib/saml2/response.rb', line 269

def assertions
  @assertions = load_object_array(xml, "saml:Assertion", Assertion) unless instance_variable_defined?(:@assertions)
  @assertions
end

#from_xml(node) ⇒ void

This method returns an undefined value.

Parse an XML element into this object.

Parameters:

  • node (Nokogiri::XML::Element)


75
76
77
78
# File 'lib/saml2/response.rb', line 75

def from_xml(node)
  super
  remove_instance_variable(:@assertions)
end

#sign(x509_certificate, private_key, algorithm_name = :sha256) ⇒ self

Sign this object.

Signs each assertion.

Parameters:

  • x509_certificate (String)

    The certificate corresponding to private_key, to be embedded in the signature.

  • private_key (String)

    The key to use to sign.

  • algorithm_name (Symbol) (defaults to: :sha256)

Returns:

  • (self)


276
277
278
279
280
281
282
283
284
285
# File 'lib/saml2/response.rb', line 276

def sign(x509_certificate, private_key, algorithm_name = :sha256)
  # make sure we no longer pretty print this object
  @pretty = false

  # if there are no assertions (encrypted?), just sign the response itself
  return super if assertions.empty?

  assertions.each { |assertion| assertion.sign(x509_certificate, private_key, algorithm_name) }
  self
end

#validate(service_provider:, identity_provider:, verification_time: nil, ignore_audience_condition: false) ⇒ Object

Validates a response is well-formed, signed, and optionally decrypts it

Parameters:

  • service_provider (Entity)

    The metadata object for the ServiceProvider receiving this SAML2::Response. The first ServiceProvider in the Entity is used.

  • identity_provider (Entity)

    The metadata object for the IdentityProvider the SAML2::Response is being received from. The first IdentityProvider in the Entity is used.

  • verification_time (defaults to: nil)

    optional [DateTime] Validate timestamps (signing certificate validity, issued at, etc.) as of this point in time.

  • ignore_audience_condition (defaults to: false)

    optional [true, false] Don’t validate any Audience conditions.

Raises:

  • (ArgumentError)


94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
# File 'lib/saml2/response.rb', line 94

def validate(service_provider:,
             identity_provider:,
             verification_time: nil,
             ignore_audience_condition: false)
  raise ArgumentError, "service_provider should be an Entity object" unless service_provider.is_a?(Entity)

  unless (sp = service_provider.service_providers.first)
    raise ArgumentError,
          "service_provider should have at least one service_provider role"
  end

  # validate the schema
  super()
  return errors unless errors.empty?

  if verification_time.nil?
    verification_time = Time.now.utc
    # they issued it in the (near) future according to our clock;
    # use their clock instead
    if issue_instant > verification_time && issue_instant < verification_time + (5 * 60)
      verification_time = issue_instant
    end
  end

  # not finding the issuer is not exceptional
  if identity_provider.nil?
    errors << "could not find issuer of response"
    return errors
  end

  # getting the wrong data type is exceptional, and we should raise an error
  raise ArgumentError, "identity_provider should be an Entity object" unless identity_provider.is_a?(Entity)

  unless (idp = identity_provider.identity_providers.first)
    raise ArgumentError,
          "identity_provider should have at least one identity_provider role"
  end

  issuer = self.issuer || assertions.first&.issuer
  unless identity_provider.entity_id == issuer&.id
    errors << "received unexpected message from '#{issuer&.id}'; " \
              "expected it to be from '#{identity_provider.entity_id}'"
    return errors
  end

  certificates = idp.signing_keys.filter_map(&:certificate)
  keys = idp.signing_keys.filter_map(&:key)
  if idp.fingerprints.empty? && certificates.empty? && keys.empty?
    errors << "could not find certificate to validate message"
    return errors
  end

  if signed?
    unless (signature_errors = validate_signature(key: keys,
                                                  fingerprint: idp.fingerprints,
                                                  cert: certificates)).empty?
      return errors.concat(signature_errors)
    end

    response_signed = true
  end

  assertion = assertions.first

  # this might be nil, if the assertion was encrypted
  if assertion&.signed?
    unless (signature_errors = assertion.validate_signature(key: keys,
                                                            fingerprint: idp.fingerprints,
                                                            cert: certificates)).empty?
      return errors.concat(signature_errors)
    end

    assertion_signed = true
  end

  find_decryption_key = lambda do |embedded_certificates|
    key = nil
    embedded_certificates.each do |cert_info|
      cert = case cert_info
             when OpenSSL::X509::Certificate then cert_info
             when Hash then sp.encryption_keys.map(&:certificate).find { |c| c.serial == cert_info[:serial] }
             end
      next unless cert

      key = sp.private_keys.find { |k| cert.check_private_key(k) }
      break if key
    end
    unless key
      # couldn't figure out which key to use; just try them all
      next sp.private_keys
    end

    key
  end

  unless sp.private_keys.empty?
    begin
      decypted_anything = decrypt(&find_decryption_key)
    rescue XMLSec::DecryptionError
      errors << "unable to decrypt response"
      return errors
    end

    if decypted_anything
      # have to re-validate the schema, since we just replaced content
      super()
      # also clear this cached value so that we can see cached assertions
      remove_instance_variable(:@assertions)
      return errors unless errors.empty?
    end
  end

  unless status.success?
    errors << "response is not successful: #{status}"
    return errors
  end

  assertion ||= assertions.first
  unless assertion
    errors << "no assertion found"
    return errors
  end

  # if we didn't previously check the assertion's signature (because it was encrypted)
  # check it now
  if assertion.signed? && !assertion_signed
    unless (signature_errors = assertion.validate_signature(fingerprint: idp.fingerprints,
                                                            cert: certificates)).empty?
      return errors.concat(signature_errors)
    end

    assertion_signed = true
  end

  # only do our own issue instant validation if the assertion
  # doesn't mandate any
  if !assertion.conditions&.not_on_or_after && (assertion.issue_instant + (5 * 60) < verification_time ||
       assertion.issue_instant - (5 * 60) > verification_time)
    errors << "assertion not recently issued"
    return errors
  end

  if assertion.conditions &&
     !(condition_errors = assertion.conditions.validate(
       verification_time: verification_time,
       audience: service_provider.entity_id,
       ignore_audience_condition: ignore_audience_condition
     )).empty?
    return errors.concat(condition_errors)
  end

  if !response_signed && !assertion_signed
    errors << "neither response nor assertion were signed"
    return errors
  end

  unless sp.private_keys.empty?
    begin
      decypted_anything = assertion.decrypt(&find_decryption_key)
    rescue XMLSec::DecryptionError
      errors << "unable to decrypt assertion"
      return errors
    end

    if decypted_anything
      super()
      return errors unless errors.empty?
    end
  end

  # no error
  errors
end