saml2 2.0.2 → 2.1.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (45) hide show
  1. checksums.yaml +4 -4
  2. data/lib/saml2.rb +2 -0
  3. data/lib/saml2/assertion.rb +6 -0
  4. data/lib/saml2/attribute.rb +45 -13
  5. data/lib/saml2/attribute/x500.rb +32 -19
  6. data/lib/saml2/attribute_consuming_service.rb +52 -4
  7. data/lib/saml2/authn_request.rb +39 -3
  8. data/lib/saml2/authn_statement.rb +23 -11
  9. data/lib/saml2/base.rb +36 -0
  10. data/lib/saml2/bindings.rb +3 -1
  11. data/lib/saml2/bindings/http_post.rb +17 -1
  12. data/lib/saml2/bindings/http_redirect.rb +54 -9
  13. data/lib/saml2/conditions.rb +43 -16
  14. data/lib/saml2/contact.rb +17 -6
  15. data/lib/saml2/endpoint.rb +13 -0
  16. data/lib/saml2/engine.rb +2 -0
  17. data/lib/saml2/entity.rb +20 -0
  18. data/lib/saml2/identity_provider.rb +11 -1
  19. data/lib/saml2/indexed_object.rb +13 -3
  20. data/lib/saml2/key.rb +89 -32
  21. data/lib/saml2/localized_name.rb +8 -0
  22. data/lib/saml2/logout_request.rb +12 -3
  23. data/lib/saml2/logout_response.rb +9 -0
  24. data/lib/saml2/message.rb +38 -7
  25. data/lib/saml2/name_id.rb +42 -16
  26. data/lib/saml2/namespaces.rb +10 -8
  27. data/lib/saml2/organization.rb +5 -0
  28. data/lib/saml2/organization_and_contacts.rb +5 -0
  29. data/lib/saml2/request.rb +3 -0
  30. data/lib/saml2/requested_authn_context.rb +7 -1
  31. data/lib/saml2/response.rb +20 -2
  32. data/lib/saml2/role.rb +12 -2
  33. data/lib/saml2/schemas.rb +2 -0
  34. data/lib/saml2/service_provider.rb +6 -0
  35. data/lib/saml2/signable.rb +32 -2
  36. data/lib/saml2/sso.rb +7 -0
  37. data/lib/saml2/status.rb +8 -1
  38. data/lib/saml2/status_response.rb +7 -1
  39. data/lib/saml2/subject.rb +22 -5
  40. data/lib/saml2/version.rb +3 -1
  41. data/spec/lib/bindings/http_redirect_spec.rb +23 -2
  42. data/spec/lib/conditions_spec.rb +10 -11
  43. data/spec/lib/identity_provider_spec.rb +1 -1
  44. data/spec/lib/service_provider_spec.rb +7 -2
  45. metadata +5 -5
@@ -1,22 +1,33 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require 'saml2/base'
2
4
 
3
5
  module SAML2
4
6
  class AuthnStatement < Base
5
7
  module Classes
6
- INTERNET_PROTOCOL = "urn:oasis:names:tc:SAML:2.0:ac:classes:InternetProtocol".freeze # IP address
7
- INTERNET_PROTOCOL_PASSWORD = "urn:oasis:names:tc:SAML:2.0:ac:classes:InternetProtocolPassword".freeze # IP address, as well as username/password
8
- KERBEROS = "urn:oasis:names:tc:SAML:2.0:ac:classes:Kerberos".freeze
9
- PASSWORD = "urn:oasis:names:tc:SAML:2.0:ac:classes:Password".freeze # username/password, NOT over SSL
10
- PASSWORD_PROTECTED_TRANSPORT = "urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport".freeze # username/password over SSL
11
- PREVIOUS_SESSION = "urn:oasis:names:tc:SAML:2.0:ac:classes:PreviousSession".freeze # remember me
12
- SMARTCARD = "urn:oasis:names:tc:SAML:2.0:ac:classes:Smartcard".freeze
13
- SMARTCARD_PKI = "urn:oasis:names:tc:SAML:2.0:ac:classes:SmartcardPKI".freeze # smartcard with a private key on it
14
- TLS_CLIENT = "urn:oasis:names:tc:SAML:2.0:ac:classes:TLSClient".freeze # SSL client certificate
15
- UNSPECIFIED = "urn:oasis:names:tc:SAML:2.0:ac:classes:unspecified".freeze
8
+ INTERNET_PROTOCOL = "urn:oasis:names:tc:SAML:2.0:ac:classes:InternetProtocol" # IP address
9
+ INTERNET_PROTOCOL_PASSWORD = "urn:oasis:names:tc:SAML:2.0:ac:classes:InternetProtocolPassword" # IP address, as well as username/password
10
+ KERBEROS = "urn:oasis:names:tc:SAML:2.0:ac:classes:Kerberos"
11
+ PASSWORD = "urn:oasis:names:tc:SAML:2.0:ac:classes:Password" # username/password, NOT over SSL
12
+ PASSWORD_PROTECTED_TRANSPORT = "urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport" # username/password over SSL
13
+ PREVIOUS_SESSION = "urn:oasis:names:tc:SAML:2.0:ac:classes:PreviousSession" # remember me
14
+ SMARTCARD = "urn:oasis:names:tc:SAML:2.0:ac:classes:Smartcard"
15
+ SMARTCARD_PKI = "urn:oasis:names:tc:SAML:2.0:ac:classes:SmartcardPKI" # smartcard with a private key on it
16
+ TLS_CLIENT = "urn:oasis:names:tc:SAML:2.0:ac:classes:TLSClient" # SSL client certificate
17
+ UNSPECIFIED = "urn:oasis:names:tc:SAML:2.0:ac:classes:unspecified"
16
18
  end
17
19
 
18
- attr_accessor :authn_instant, :authn_context_class_ref, :session_index, :session_not_on_or_after
20
+ # @return [Time]
21
+ attr_accessor :authn_instant
22
+ # One of the values in {Classes}.
23
+ # @return [String, nil]
24
+ attr_accessor :authn_context_class_ref
25
+ # @return [String, nil]
26
+ attr_accessor :session_index
27
+ # @return [Time, nil]
28
+ attr_accessor :session_not_on_or_after
19
29
 
30
+ # (see Base#from_xml)
20
31
  def from_xml(node)
21
32
  super
22
33
  @authn_instant = Time.parse(node['AuthnInstant'])
@@ -25,6 +36,7 @@ module SAML2
25
36
  @authn_context_class_ref = node.at_xpath('saml:AuthnContext/saml:AuthnContextClassRef', Namespaces::ALL)&.content&.strip
26
37
  end
27
38
 
39
+ # (see Base#build)
28
40
  def build(builder)
29
41
  builder['saml'].AuthnStatement('AuthnInstant' => authn_instant.iso8601) do |authn_statement|
30
42
  authn_statement.parent['SessionIndex'] = session_index if session_index
@@ -1,7 +1,14 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require 'saml2/namespaces'
2
4
 
3
5
  module SAML2
6
+ # @abstract
4
7
  class Base
8
+ # Create an appropriate object to represent the given XML element.
9
+ #
10
+ # @param node [Nokogiri::XML::Element, nil]
11
+ # @return [Base, nil]
5
12
  def self.from_xml(node)
6
13
  return nil unless node
7
14
  result = new
@@ -9,16 +16,31 @@ module SAML2
9
16
  result
10
17
  end
11
18
 
19
+ # @return [Nokogiri::XML::Element]
12
20
  attr_reader :xml
13
21
 
14
22
  def initialize
15
23
  @pretty = true
16
24
  end
17
25
 
26
+ # Parse an XML element into this object.
27
+ #
28
+ # @param node [Nokogiri::XML::Element]
29
+ # @return [void]
18
30
  def from_xml(node)
19
31
  @xml = node
20
32
  end
21
33
 
34
+ # Returns the XML of this object as a string.
35
+ #
36
+ # If this object came from parsing XML, it will always return it with the
37
+ # same formatting as it was parsed.
38
+ #
39
+ # @param pretty optional [true, false, nil]
40
+ # +true+ forces it to format it for easy reading. +nil+ will prefer to
41
+ # format it pretty, but won't if e.g. it has been signed, and pretty
42
+ # formatting would break the signature.
43
+ # @return [String]
22
44
  def to_s(pretty: nil)
23
45
  pretty = @pretty if pretty.nil?
24
46
  if xml
@@ -31,10 +53,19 @@ module SAML2
31
53
  end
32
54
  end
33
55
 
56
+ # Inspect the object
57
+ #
58
+ # The +@xml+ instance variable is omitted, keeping this useful. However, if
59
+ # an object lazily parses sub-objects, then their instance variables will
60
+ # not be created until their attribute is accessed.
61
+ # @return [String]
34
62
  def inspect
35
63
  "#<#{self.class.name} #{instance_variables.map { |iv| next if iv == :@xml; "#{iv}=#{instance_variable_get(iv).inspect}" }.compact.join(", ") }>"
36
64
  end
37
65
 
66
+ # Serialize this object to XML
67
+ #
68
+ # @return [Nokogiri::XML::Document]
38
69
  def to_xml
39
70
  unless instance_variable_defined?(:@document)
40
71
  builder = Nokogiri::XML::Builder.new
@@ -47,6 +78,10 @@ module SAML2
47
78
  @document
48
79
  end
49
80
 
81
+ # Serialize this object to XML, as part of a larger document
82
+ #
83
+ # @param builder [Nokogiri::XML::Builder] The builder helper object to serialize to.
84
+ # @return [void]
50
85
  def build(builder)
51
86
  end
52
87
 
@@ -56,6 +91,7 @@ module SAML2
56
91
  end
57
92
  end
58
93
 
94
+
59
95
  def self.load_object_array(node, element, klass = nil)
60
96
  node.xpath(element, Namespaces::ALL).map do |element_node|
61
97
  if klass.nil?
@@ -1,7 +1,9 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module SAML2
2
4
  module Bindings
3
5
  module Encodings
4
- DEFLATE = 'urn:oasis:names:tc:SAML:2.0:bindings:URL-Encoding:DEFLATE'.freeze
6
+ DEFLATE = 'urn:oasis:names:tc:SAML:2.0:bindings:URL-Encoding:DEFLATE'
5
7
  end
6
8
  end
7
9
  end
@@ -1,11 +1,20 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require 'base64'
2
4
 
3
5
  module SAML2
4
6
  module Bindings
5
7
  module HTTP_POST
6
- URN ="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST".freeze
8
+ URN ="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST"
7
9
 
8
10
  class << self
11
+ # Decode and parse a Base64 encoded SAML message.
12
+ #
13
+ # @param post_params [Hash<String => String>]
14
+ # The POST params. Will check for both +SAMLRequest+ and
15
+ # +SAMLResponse+ params.
16
+ # @return [[Message, String]]
17
+ # The Message and the RelayState.
9
18
  def decode(post_params)
10
19
  base64 = post_params['SAMLRequest'] || post_params['SAMLResponse']
11
20
  raise MissingMessage unless base64
@@ -22,6 +31,13 @@ module SAML2
22
31
  [message, post_params['RelayState']]
23
32
  end
24
33
 
34
+ # Encode a SAML message into Base64 POST params.
35
+ #
36
+ # @param message [Message]
37
+ # @param relay_state optional [String]
38
+ # @return [Hash<String => String>]
39
+ # The POST params, including +RelayState+, and +SAMLRequest+ vs.
40
+ # +SAMLResponse+ chosen appropriately.
25
41
  def encode(message, relay_state: nil)
26
42
  xml = message.to_s(pretty: false)
27
43
  key = message.is_a?(Request) ? 'SAMLRequest' : 'SAMLResponse'
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require 'base64'
2
4
  require 'uri'
3
5
  require 'zlib'
@@ -8,16 +10,39 @@ require 'saml2/message'
8
10
  module SAML2
9
11
  module Bindings
10
12
  module HTTPRedirect
11
- URN ="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect".freeze
13
+ URN ="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect"
12
14
 
13
15
  module SigAlgs
14
- DSA_SHA1 = "http://www.w3.org/2000/09/xmldsig#dsa-sha1".freeze
15
- RSA_SHA1 = "http://www.w3.org/2000/09/xmldsig#rsa-sha1".freeze
16
+ DSA_SHA1 = "http://www.w3.org/2000/09/xmldsig#dsa-sha1"
17
+ RSA_SHA1 = "http://www.w3.org/2000/09/xmldsig#rsa-sha1"
18
+ RSA_SHA256 = "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256"
16
19
 
17
- RECOGNIZED = [DSA_SHA1, RSA_SHA1].freeze
20
+ RECOGNIZED = [DSA_SHA1, RSA_SHA1, RSA_SHA256].freeze
18
21
  end
19
22
 
20
23
  class << self
24
+ # Decode, validate signature, and parse a compressed and Base64 encoded
25
+ # SAML message.
26
+ #
27
+ # A signature, if present, will be verified only if +public_key+ is
28
+ # passed.
29
+ #
30
+ # @param url [String]
31
+ # The full URL to decode. Will check for both +SAMLRequest+ and
32
+ # +SAMLResponse+ params.
33
+ # @param public_key optional [Array<OpenSSL::PKey>, OpenSSL::PKey, Proc]
34
+ # Keys to use to check the signature. If a +Proc+ is provided, it is
35
+ # called with the parsed {Message}, and the +SigAlg+ in order for the
36
+ # caller to find an appropriate key based on the {Message}'s issuer.
37
+ # @param public_key_used optional [Proc]
38
+ # Is called with the actual key that was used to validate the
39
+ # signature.
40
+ # @return [[Message, String]]
41
+ # The Message and the RelayState.
42
+ # @raise [UnsignedMessage] If a public_key is provided, but the message
43
+ # is not signed.
44
+ # @yield [message, sig_alg]
45
+ # The same as a +Proc+ provided to +public_key+. Deprecated.
21
46
  def decode(url, public_key: nil, public_key_used: nil)
22
47
  uri = begin
23
48
  URI.parse(url)
@@ -51,7 +76,7 @@ module SAML2
51
76
  end
52
77
 
53
78
  zstream = Zlib::Inflate.new(-Zlib::MAX_WBITS)
54
- xml = ''
79
+ xml = String.new
55
80
  begin
56
81
  # do it in 1K slices, so we can protect against bombs
57
82
  (0..deflated.bytesize / 1024).each do |i|
@@ -69,6 +94,7 @@ module SAML2
69
94
  # if a block is provided, it's to fetch the proper certificate
70
95
  # based on the contents of the message
71
96
  public_key ||= yield(message, sig_alg) if block_given?
97
+ public_key = public_key.call(message, sig_alg) if public_key.is_a?(Proc)
72
98
  if public_key
73
99
  raise UnsignedMessage unless signature
74
100
  raise UnsupportedSignatureAlgorithm unless SigAlgs::RECOGNIZED.include?(sig_alg)
@@ -86,7 +112,8 @@ module SAML2
86
112
  valid_signature = false
87
113
  # there could be multiple certificates to try
88
114
  Array(public_key).each do |key|
89
- if key.verify(OpenSSL::Digest::SHA1.new, signature, base_string)
115
+ hash = (sig_alg == SigAlgs::RSA_SHA256 ? OpenSSL::Digest::SHA256 : OpenSSL::Digest::SHA1)
116
+ if key.verify(hash.new, signature, base_string)
90
117
  # notify the caller which certificate was used
91
118
  public_key_used&.call(key)
92
119
  valid_signature = true
@@ -98,7 +125,22 @@ module SAML2
98
125
  [message, relay_state]
99
126
  end
100
127
 
101
- def encode(message, relay_state: nil, private_key: nil)
128
+ # Encode a SAML message into Base64, compressed query params.
129
+ #
130
+ # @param message [Message]
131
+ # Note that the base URI is taken from {Message#destination}.
132
+ # @param relay_state optional [String]
133
+ # @param private_key optional [OpenSSL::PKey::RSA]
134
+ # A key to use to sign the encoded message.
135
+ # @param sig_alg optional [String]
136
+ # The signing algorithm to use. Defaults to RSA-SHA1, as it's the
137
+ # most compatible, and explicitly mentioned in the SAML specs, but
138
+ # you may want to use RSA-SHA256. Values must come from {SigAlgs}.
139
+ # @return [String]
140
+ # The full URI to redirect to, including +RelayState+, and
141
+ # +SAMLRequest+ vs. +SAMLResponse+ chosen appropriately, and
142
+ # +Signature+ + +SigAlg+ query params if signing.
143
+ def encode(message, relay_state: nil, private_key: nil, sig_alg: SigAlgs::RSA_SHA1)
102
144
  result = URI.parse(message.destination)
103
145
  original_query = URI.decode_www_form(result.query) if result.query
104
146
  original_query ||= []
@@ -117,9 +159,12 @@ module SAML2
117
159
  query << [message.is_a?(Request) ? 'SAMLRequest' : 'SAMLResponse', base64]
118
160
  query << ['RelayState', relay_state] if relay_state
119
161
  if private_key
120
- query << ['SigAlg', SigAlgs::RSA_SHA1]
162
+ raise ArgumentError, "Unsupported signature algorithm #{sig_alg}" unless SigAlgs::RECOGNIZED.include?(sig_alg)
163
+
164
+ query << ['SigAlg', sig_alg]
121
165
  base_string = URI.encode_www_form(query)
122
- signature = private_key.sign(OpenSSL::Digest::SHA1.new, base_string)
166
+ hash = (sig_alg == SigAlgs::RSA_SHA256 ? OpenSSL::Digest::SHA256 : OpenSSL::Digest::SHA1)
167
+ signature = private_key.sign(hash.new, base_string)
123
168
  query << ['Signature', Base64.strict_encode64(signature)]
124
169
  end
125
170
 
@@ -1,16 +1,22 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require 'active_support/core_ext/array/wrap'
2
4
 
3
5
  module SAML2
4
6
  class Conditions < Array
7
+ # @return [Time, nil]
5
8
  attr_accessor :not_before, :not_on_or_after
9
+ # (see Base#xml)
6
10
  attr_reader :xml
7
11
 
12
+ # (see Base.from_xml)
8
13
  def self.from_xml(node)
9
14
  result = new
10
15
  result.from_xml(node)
11
16
  result
12
17
  end
13
18
 
19
+ # (see Base#from_xml)
14
20
  def from_xml(node)
15
21
  @xml = node
16
22
  @not_before = Time.parse(node['NotBefore']) if node['NotBefore']
@@ -19,20 +25,30 @@ module SAML2
19
25
  replace(node.children.map { |restriction| self.class.const_get(restriction.name, false).from_xml(restriction) })
20
26
  end
21
27
 
22
- def valid?(options = {})
23
- now = options[:now] || Time.now
24
- return :invalid if not_before && now < not_before
25
- return :invalid if not_on_or_after && now >= not_on_or_after
26
-
27
- result = :valid
28
+ # Evaluate these conditions.
29
+ #
30
+ # @param now optional [Time]
31
+ # @param options
32
+ # Additional options to pass to specific {Condition}s
33
+ # @return [Boolean, nil]
34
+ # It's only valid if every sub-condition is completely valid.
35
+ # If any sub-condition is invalid, the whole statement is invalid.
36
+ # If the validity can't be determined due to an unsupported condition,
37
+ # +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
42
+
43
+ result = true
28
44
  each do |condition|
29
- this_result = condition.valid?(options)
45
+ this_result = condition.valid?(**options)
30
46
  case this_result
31
- when :invalid
32
- return :invalid
33
- when :indeterminate
34
- result = :indeterminate
35
- when :valid
47
+ when false
48
+ return false
49
+ when nil
50
+ result = nil
51
+ when true
36
52
  else
37
53
  raise "unknown validity of #{condition}"
38
54
  end
@@ -40,6 +56,7 @@ module SAML2
40
56
  result
41
57
  end
42
58
 
59
+ # (see Base#build)
43
60
  def build(builder)
44
61
  builder['saml'].Conditions do |conditions|
45
62
  conditions.parent['NotBefore'] = not_before.iso8601 if not_before
@@ -53,31 +70,37 @@ module SAML2
53
70
 
54
71
  # Any unknown condition
55
72
  class Condition < Base
73
+ # @return [nil]
56
74
  def valid?(_)
57
- :indeterminate
75
+ nil
58
76
  end
59
77
  end
60
78
 
61
79
  class AudienceRestriction < Condition
62
80
  attr_writer :audience
63
81
 
82
+ # @param audience [Array<String>]
64
83
  def initialize(audience = [])
65
84
  @audience = audience
66
85
  end
67
86
 
87
+ # (see Base#from_xml)
68
88
  def from_xml(node)
69
89
  super
70
90
  @audience = nil
71
91
  end
72
92
 
93
+ # @return [Array<String>] Allowed audiences
73
94
  def audience
74
95
  @audience ||= load_string_array(xml, 'saml:Audience')
75
96
  end
76
97
 
77
- def valid?(options)
78
- Array.wrap(audience).include?(options[:audience]) ? :valid : :invalid
98
+ # @param audience [String]
99
+ def valid?(audience: nil, **_)
100
+ Array.wrap(self.audience).include?(audience)
79
101
  end
80
102
 
103
+ # (see Base#build)
81
104
  def build(builder)
82
105
  builder['saml'].AudienceRestriction do |audience_restriction|
83
106
  Array.wrap(audience).each do |single_audience|
@@ -88,10 +111,14 @@ module SAML2
88
111
  end
89
112
 
90
113
  class OneTimeUse < Condition
114
+ # The caller will need to see if this condition exists, and validate it
115
+ # using their own state store.
116
+ # @return [true]
91
117
  def valid?(_)
92
- :valid
118
+ true
93
119
  end
94
120
 
121
+ # (see Base#build)
95
122
  def build(builder)
96
123
  builder['saml'].OneTimeUse
97
124
  end
@@ -1,23 +1,33 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require 'saml2/base'
2
4
 
3
5
  module SAML2
4
6
  class Contact < Base
5
7
  module Type
6
- ADMINISTRATIVE = 'administrative'.freeze
7
- BILLING = 'billing'.freeze
8
- OTHER = 'other'.freeze
9
- SUPPORT = 'support'.freeze
10
- TECHNICAL = 'technical'.freeze
8
+ ADMINISTRATIVE = 'administrative'
9
+ BILLING = 'billing'
10
+ OTHER = 'other'
11
+ SUPPORT = 'support'
12
+ TECHNICAL = 'technical'
11
13
  end
12
14
 
13
- attr_accessor :type, :company, :given_name, :surname, :email_addresses, :telephone_numbers
15
+ # @see Type
16
+ # @return [String]
17
+ attr_accessor :type
18
+ # @return [String, nil]
19
+ attr_accessor :company, :given_name, :surname
20
+ # @return [Array<String>]
21
+ attr_accessor :email_addresses, :telephone_numbers
14
22
 
23
+ # @param type [String]
15
24
  def initialize(type = Type::OTHER)
16
25
  @type = type
17
26
  @email_addresses = []
18
27
  @telephone_numbers = []
19
28
  end
20
29
 
30
+ # (see Base#from_xml)
21
31
  def from_xml(node)
22
32
  self.type = node['contactType']
23
33
  company = node.at_xpath('md:Company', Namespaces::ALL)
@@ -31,6 +41,7 @@ module SAML2
31
41
  self
32
42
  end
33
43
 
44
+ # (see Base#build)
34
45
  def build(builder)
35
46
  builder['md'].ContactPerson('contactType' => type) do |contact_person|
36
47
  contact_person['md'].Company(company) if company