saml2 2.0.2 → 2.1.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 (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