saml-kit 0.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 (60) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +12 -0
  3. data/.rspec +2 -0
  4. data/.travis.yml +5 -0
  5. data/Gemfile +6 -0
  6. data/LICENSE.txt +21 -0
  7. data/README.md +39 -0
  8. data/Rakefile +6 -0
  9. data/bin/console +14 -0
  10. data/bin/setup +8 -0
  11. data/exe/saml-kit-decode-http-redirect +7 -0
  12. data/lib/saml/kit.rb +60 -0
  13. data/lib/saml/kit/authentication_request.rb +78 -0
  14. data/lib/saml/kit/binding.rb +40 -0
  15. data/lib/saml/kit/configuration.rb +36 -0
  16. data/lib/saml/kit/default_registry.rb +49 -0
  17. data/lib/saml/kit/document.rb +96 -0
  18. data/lib/saml/kit/fingerprint.rb +40 -0
  19. data/lib/saml/kit/http_post_binding.rb +27 -0
  20. data/lib/saml/kit/http_redirect_binding.rb +58 -0
  21. data/lib/saml/kit/identity_provider_metadata.rb +122 -0
  22. data/lib/saml/kit/invalid_document.rb +13 -0
  23. data/lib/saml/kit/locales/en.yml +25 -0
  24. data/lib/saml/kit/logout_request.rb +78 -0
  25. data/lib/saml/kit/logout_response.rb +63 -0
  26. data/lib/saml/kit/metadata.rb +171 -0
  27. data/lib/saml/kit/namespaces.rb +47 -0
  28. data/lib/saml/kit/requestable.rb +14 -0
  29. data/lib/saml/kit/respondable.rb +35 -0
  30. data/lib/saml/kit/response.rb +197 -0
  31. data/lib/saml/kit/self_signed_certificate.rb +30 -0
  32. data/lib/saml/kit/serializable.rb +31 -0
  33. data/lib/saml/kit/service_provider_metadata.rb +99 -0
  34. data/lib/saml/kit/signature.rb +75 -0
  35. data/lib/saml/kit/trustable.rb +62 -0
  36. data/lib/saml/kit/url_builder.rb +38 -0
  37. data/lib/saml/kit/version.rb +5 -0
  38. data/lib/saml/kit/xml.rb +57 -0
  39. data/lib/saml/kit/xsd/MetadataExchange.xsd +95 -0
  40. data/lib/saml/kit/xsd/oasis-200401-wss-wssecurity-secext-1.0.xsd +196 -0
  41. data/lib/saml/kit/xsd/oasis-200401-wss-wssecurity-utility-1.0.xsd +95 -0
  42. data/lib/saml/kit/xsd/saml-schema-assertion-2.0.xsd +283 -0
  43. data/lib/saml/kit/xsd/saml-schema-authn-context-2.0.xsd +23 -0
  44. data/lib/saml/kit/xsd/saml-schema-authn-context-types-2.0.xsd +821 -0
  45. data/lib/saml/kit/xsd/saml-schema-metadata-2.0.xsd +335 -0
  46. data/lib/saml/kit/xsd/saml-schema-protocol-2.0.xsd +302 -0
  47. data/lib/saml/kit/xsd/sstc-metadata-attr.xsd +35 -0
  48. data/lib/saml/kit/xsd/sstc-saml-attribute-ext.xsd +25 -0
  49. data/lib/saml/kit/xsd/sstc-saml-metadata-algsupport-v1.0.xsd +41 -0
  50. data/lib/saml/kit/xsd/sstc-saml-metadata-ui-v1.0.xsd +89 -0
  51. data/lib/saml/kit/xsd/ws-addr.xsd +120 -0
  52. data/lib/saml/kit/xsd/ws-authorization.xsd +145 -0
  53. data/lib/saml/kit/xsd/ws-federation.xsd +471 -0
  54. data/lib/saml/kit/xsd/ws-securitypolicy-1.2.xsd +900 -0
  55. data/lib/saml/kit/xsd/xenc-schema.xsd +136 -0
  56. data/lib/saml/kit/xsd/xml.xsd +287 -0
  57. data/lib/saml/kit/xsd/xmldsig-core-schema.xsd +309 -0
  58. data/lib/saml/kit/xsd_validatable.rb +19 -0
  59. data/saml-kit.gemspec +35 -0
  60. metadata +243 -0
@@ -0,0 +1,40 @@
1
+ module Saml
2
+ module Kit
3
+ class Fingerprint
4
+ attr_reader :x509
5
+
6
+ def initialize(raw_certificate)
7
+ @x509 = OpenSSL::X509::Certificate.new(raw_certificate)
8
+ rescue OpenSSL::X509::CertificateError => error
9
+ Saml::Kit.logger.warn(error)
10
+ @x509 = OpenSSL::X509::Certificate.new(Base64.decode64(raw_certificate))
11
+ end
12
+
13
+ def algorithm(algorithm)
14
+ pretty_fingerprint(algorithm.new.hexdigest(x509.to_der))
15
+ end
16
+
17
+ def ==(other)
18
+ self.to_s == other.to_s
19
+ end
20
+
21
+ def eql?(other)
22
+ self == other
23
+ end
24
+
25
+ def hash
26
+ to_s.hash
27
+ end
28
+
29
+ def to_s
30
+ algorithm(OpenSSL::Digest::SHA256)
31
+ end
32
+
33
+ private
34
+
35
+ def pretty_fingerprint(fingerprint)
36
+ fingerprint.upcase.scan(/../).join(":")
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,27 @@
1
+ module Saml
2
+ module Kit
3
+ class HttpPostBinding < Binding
4
+ include Serializable
5
+
6
+ def initialize(location:)
7
+ super(binding: Saml::Kit::Namespaces::HTTP_POST, location: location)
8
+ end
9
+
10
+ def serialize(builder, relay_state: nil)
11
+ builder.sign = true
12
+ builder.destination = location
13
+ document = builder.build
14
+ saml_params = {
15
+ document.query_string_parameter => Base64.strict_encode64(document.to_xml),
16
+ }
17
+ saml_params['RelayState'] = relay_state if relay_state.present?
18
+ [location, saml_params]
19
+ end
20
+
21
+ def deserialize(params)
22
+ xml = decode(saml_param_from(params))
23
+ Saml::Kit::Document.to_saml_document(xml)
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,58 @@
1
+ module Saml
2
+ module Kit
3
+ class HttpRedirectBinding < Binding
4
+ include Serializable
5
+
6
+ def initialize(location:)
7
+ super(binding: Saml::Kit::Namespaces::HTTP_REDIRECT, location: location)
8
+ end
9
+
10
+ def serialize(builder, relay_state: nil)
11
+ builder.sign = false
12
+ builder.destination = location
13
+ document = builder.build
14
+ [UrlBuilder.new.build(document, relay_state: relay_state), {}]
15
+ end
16
+
17
+ def deserialize(params)
18
+ document = deserialize_document_from!(params)
19
+ ensure_valid_signature!(params, document)
20
+ document
21
+ end
22
+
23
+ private
24
+
25
+ def deserialize_document_from!(params)
26
+ xml = inflate(decode(unescape(saml_param_from(params))))
27
+ Saml::Kit.logger.debug(xml)
28
+ Saml::Kit::Document.to_saml_document(xml)
29
+ end
30
+
31
+ def ensure_valid_signature!(params, document)
32
+ return if params['Signature'].blank? || params['SigAlg'].blank?
33
+
34
+ signature = decode(params['Signature'])
35
+ canonical_form = ['SAMLRequest', 'SAMLResponse', 'RelayState', 'SigAlg'].map do |key|
36
+ value = params[key]
37
+ value.present? ? "#{key}=#{value}" : nil
38
+ end.compact.join('&')
39
+
40
+ valid = document.provider.verify(algorithm_for(params['SigAlg']), signature, canonical_form)
41
+ raise ArgumentError.new("Invalid Signature") unless valid
42
+ end
43
+
44
+ def algorithm_for(algorithm)
45
+ case algorithm =~ /(rsa-)?sha(.*?)$/i && $2.to_i
46
+ when 256
47
+ OpenSSL::Digest::SHA256.new
48
+ when 384
49
+ OpenSSL::Digest::SHA384.new
50
+ when 512
51
+ OpenSSL::Digest::SHA512.new
52
+ else
53
+ OpenSSL::Digest::SHA1.new
54
+ end
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,122 @@
1
+ module Saml
2
+ module Kit
3
+ class IdentityProviderMetadata < Metadata
4
+ def initialize(xml)
5
+ super("IDPSSODescriptor", xml)
6
+ end
7
+
8
+ def want_authn_requests_signed
9
+ xpath = "/md:EntityDescriptor/md:#{name}"
10
+ attribute = find_by(xpath).attribute("WantAuthnRequestsSigned")
11
+ return true if attribute.nil?
12
+ attribute.text.downcase == "true"
13
+ end
14
+
15
+ def single_sign_on_services
16
+ services('SingleSignOnService')
17
+ end
18
+
19
+ def single_sign_on_service_for(binding:)
20
+ service_for(binding: binding, type: 'SingleSignOnService')
21
+ end
22
+
23
+ def attributes
24
+ find_all("/md:EntityDescriptor/md:#{name}/saml:Attribute").map do |item|
25
+ {
26
+ format: item.attribute("NameFormat").try(:value),
27
+ name: item.attribute("Name").value,
28
+ }
29
+ end
30
+ end
31
+
32
+ private
33
+
34
+ class Builder
35
+ attr_accessor :id, :organization_name, :organization_url, :contact_email, :entity_id, :attributes, :name_id_formats
36
+ attr_accessor :want_authn_requests_signed, :sign
37
+ attr_reader :logout_urls, :single_sign_on_urls
38
+
39
+ def initialize(configuration = Saml::Kit.configuration)
40
+ @id = SecureRandom.uuid
41
+ @entity_id = configuration.issuer
42
+ @attributes = []
43
+ @name_id_formats = [Namespaces::PERSISTENT]
44
+ @single_sign_on_urls = []
45
+ @logout_urls = []
46
+ @configuration = configuration
47
+ @sign = true
48
+ @want_authn_requests_signed = true
49
+ end
50
+
51
+ def add_single_sign_on_service(url, binding: :post)
52
+ @single_sign_on_urls.push(location: url, binding: Namespaces.binding_for(binding))
53
+ end
54
+
55
+ def add_single_logout_service(url, binding: :post)
56
+ @logout_urls.push(location: url, binding: Namespaces.binding_for(binding))
57
+ end
58
+
59
+ def to_xml
60
+ Signature.sign(id, sign: sign) do |xml, signature|
61
+ xml.instruct!
62
+ xml.EntityDescriptor entity_descriptor_options do
63
+ signature.template(xml)
64
+ xml.IDPSSODescriptor idp_sso_descriptor_options do
65
+ xml.KeyDescriptor use: "signing" do
66
+ xml.KeyInfo "xmlns": Namespaces::XMLDSIG do
67
+ xml.X509Data do
68
+ xml.X509Certificate @configuration.stripped_signing_certificate
69
+ end
70
+ end
71
+ end
72
+ logout_urls.each do |item|
73
+ xml.SingleLogoutService Binding: item[:binding], Location: item[:location]
74
+ end
75
+ name_id_formats.each do |format|
76
+ xml.NameIDFormat format
77
+ end
78
+ single_sign_on_urls.each do |item|
79
+ xml.SingleSignOnService Binding: item[:binding], Location: item[:location]
80
+ end
81
+ attributes.each do |attribute|
82
+ xml.tag! 'saml:Attribute', Name: attribute
83
+ end
84
+ end
85
+ xml.Organization do
86
+ xml.OrganizationName organization_name, 'xml:lang': "en"
87
+ xml.OrganizationDisplayName organization_name, 'xml:lang': "en"
88
+ xml.OrganizationURL organization_url, 'xml:lang': "en"
89
+ end
90
+ xml.ContactPerson contactType: "technical" do
91
+ xml.Company "mailto:#{contact_email}"
92
+ end
93
+ end
94
+ end
95
+ end
96
+
97
+ def build
98
+ IdentityProviderMetadata.new(to_xml)
99
+ end
100
+
101
+ private
102
+
103
+ def entity_descriptor_options
104
+ {
105
+ 'xmlns': Namespaces::METADATA,
106
+ 'xmlns:ds': Namespaces::XMLDSIG,
107
+ 'xmlns:saml': Namespaces::ASSERTION,
108
+ ID: "_#{id}",
109
+ entityID: entity_id,
110
+ }
111
+ end
112
+
113
+ def idp_sso_descriptor_options
114
+ {
115
+ protocolSupportEnumeration: Namespaces::PROTOCOL,
116
+ WantAuthnRequestsSigned: want_authn_requests_signed
117
+ }
118
+ end
119
+ end
120
+ end
121
+ end
122
+ end
@@ -0,0 +1,13 @@
1
+ module Saml
2
+ module Kit
3
+ class InvalidDocument < Document
4
+ validate do |model|
5
+ model.errors[:base] << model.error_message(:invalid)
6
+ end
7
+
8
+ def initialize(xml)
9
+ super(xml, name: "InvalidDocument")
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,25 @@
1
+ ---
2
+ en:
3
+ saml/kit:
4
+ errors:
5
+ AuthnRequest:
6
+ invalid: "must contain AuthnRequest."
7
+ invalid_fingerprint: "does not match."
8
+ unregistered: "is unregistered."
9
+ IDPSSODescriptor:
10
+ invalid: "must contain IDPSSODescriptor."
11
+ invalid_signature: "invalid signature."
12
+ InvalidDocument:
13
+ invalid: "must contain valid SAMLRequest"
14
+ LogoutResponse:
15
+ unregistered: "is unregistered."
16
+ Response:
17
+ invalid: "must contain Response."
18
+ unregistered: "must originate from registered identity provider."
19
+ expired: "must not be expired."
20
+ invalid_version: "must be 2.0."
21
+ invalid_response_to: "must match request id."
22
+ must_match_issuer: "must match entityId."
23
+ SPSSODescriptor:
24
+ invalid: "must contain SPSSODescriptor."
25
+ invalid_signature: "invalid signature."
@@ -0,0 +1,78 @@
1
+ module Saml
2
+ module Kit
3
+ class LogoutRequest < Document
4
+ include Requestable
5
+ validates_presence_of :single_logout_service, if: :expected_type?
6
+
7
+ def initialize(xml)
8
+ super(xml, name: "LogoutRequest")
9
+ end
10
+
11
+ def name_id
12
+ to_h[name]['NameID']
13
+ end
14
+
15
+ def single_logout_service
16
+ return if provider.nil?
17
+ urls = provider.single_logout_services
18
+ urls.first
19
+ end
20
+
21
+ def response_for(user)
22
+ LogoutResponse::Builder.new(user, self)
23
+ end
24
+
25
+ private
26
+
27
+ class Builder
28
+ attr_accessor :id, :destination, :issuer, :name_id_format, :now
29
+ attr_accessor :sign, :version
30
+ attr_reader :user
31
+
32
+ def initialize(user, configuration: Saml::Kit.configuration, sign: true)
33
+ @user = user
34
+ @id = SecureRandom.uuid
35
+ @issuer = configuration.issuer
36
+ @name_id_format = Saml::Kit::Namespaces::PERSISTENT
37
+ @now = Time.now.utc
38
+ @version = "2.0"
39
+ @sign = sign
40
+ end
41
+
42
+ def to_xml
43
+ Signature.sign(id, sign: sign) do |xml, signature|
44
+ xml.instruct!
45
+ xml.LogoutRequest logout_request_options do
46
+ xml.Issuer({ xmlns: Namespaces::ASSERTION }, issuer)
47
+ signature.template(xml)
48
+ xml.NameID name_id_options, user.name_id_for(name_id_format)
49
+ end
50
+ end
51
+ end
52
+
53
+ def build
54
+ Saml::Kit::LogoutRequest.new(to_xml)
55
+ end
56
+
57
+ private
58
+
59
+ def logout_request_options
60
+ {
61
+ ID: "_#{id}",
62
+ Version: version,
63
+ IssueInstant: now.utc.iso8601,
64
+ Destination: destination,
65
+ xmlns: Namespaces::PROTOCOL,
66
+ }
67
+ end
68
+
69
+ def name_id_options
70
+ {
71
+ Format: name_id_format,
72
+ xmlns: Namespaces::ASSERTION,
73
+ }
74
+ end
75
+ end
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,63 @@
1
+ module Saml
2
+ module Kit
3
+ class LogoutResponse < Document
4
+ include Respondable
5
+
6
+ def initialize(xml, request_id: nil)
7
+ @request_id = request_id
8
+ super(xml, name: "LogoutResponse")
9
+ end
10
+
11
+ private
12
+
13
+ class Builder
14
+ attr_accessor :id, :issuer, :version, :status_code, :sign, :now, :destination
15
+ attr_reader :request
16
+
17
+ def initialize(user, request, configuration: Saml::Kit.configuration, sign: true)
18
+ @user = user
19
+ @now = Time.now.utc
20
+ @request = request
21
+ @id = SecureRandom.uuid
22
+ @version = "2.0"
23
+ @status_code = Namespaces::SUCCESS
24
+ @sign = sign
25
+ @issuer = configuration.issuer
26
+ provider = configuration.registry.metadata_for(@issuer)
27
+ if provider
28
+ @destination = provider.single_logout_service_for(binding: :post).try(:location)
29
+ end
30
+ end
31
+
32
+ def to_xml
33
+ Signature.sign(id, sign: sign) do |xml, signature|
34
+ xml.LogoutResponse logout_response_options do
35
+ xml.Issuer(issuer, xmlns: Namespaces::ASSERTION)
36
+ signature.template(xml)
37
+ xml.Status do
38
+ xml.StatusCode Value: status_code
39
+ end
40
+ end
41
+ end
42
+ end
43
+
44
+ def build
45
+ LogoutResponse.new(to_xml, request_id: request.id)
46
+ end
47
+
48
+ private
49
+
50
+ def logout_response_options
51
+ {
52
+ xmlns: Namespaces::PROTOCOL,
53
+ ID: "_#{id}",
54
+ Version: version,
55
+ IssueInstant: now.utc.iso8601,
56
+ Destination: destination,
57
+ InResponseTo: request.id,
58
+ }
59
+ end
60
+ end
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,171 @@
1
+ module Saml
2
+ module Kit
3
+ class Metadata
4
+ include ActiveModel::Validations
5
+ include XsdValidatable
6
+
7
+ METADATA_XSD = File.expand_path("./xsd/saml-schema-metadata-2.0.xsd", File.dirname(__FILE__)).freeze
8
+ NAMESPACES = {
9
+ "NameFormat": Namespaces::ATTR_SPLAT,
10
+ "ds": Namespaces::XMLDSIG,
11
+ "md": Namespaces::METADATA,
12
+ "saml": Namespaces::ASSERTION,
13
+ }.freeze
14
+
15
+ validates_presence_of :metadata
16
+ validate :must_contain_descriptor
17
+ validate :must_match_xsd
18
+ validate :must_have_valid_signature
19
+
20
+ attr_reader :xml, :name
21
+ attr_accessor :hash_algorithm
22
+
23
+ def initialize(name, xml)
24
+ @name = name
25
+ @xml = xml
26
+ @hash_algorithm = OpenSSL::Digest::SHA256
27
+ end
28
+
29
+ def entity_id
30
+ find_by("/md:EntityDescriptor/@entityID").value
31
+ end
32
+
33
+ def name_id_formats
34
+ find_all("/md:EntityDescriptor/md:#{name}/md:NameIDFormat").map(&:text)
35
+ end
36
+
37
+ def certificates
38
+ @certificates ||= find_all("/md:EntityDescriptor/md:#{name}/md:KeyDescriptor").map do |item|
39
+ cert = item.at_xpath("./ds:KeyInfo/ds:X509Data/ds:X509Certificate", NAMESPACES).text
40
+ {
41
+ text: cert,
42
+ fingerprint: Fingerprint.new(cert).algorithm(hash_algorithm),
43
+ use: item.attribute('use').value.to_sym,
44
+ }
45
+ end
46
+ end
47
+
48
+ def encryption_certificates
49
+ certificates.find_all { |x| x[:use] == :encryption }
50
+ end
51
+
52
+ def signing_certificates
53
+ certificates.find_all { |x| x[:use] == :signing }
54
+ end
55
+
56
+ def services(type)
57
+ find_all("/md:EntityDescriptor/md:#{name}/md:#{type}").map do |item|
58
+ binding = item.attribute("Binding").value
59
+ location = item.attribute("Location").value
60
+ binding_for(binding, location)
61
+ end
62
+ end
63
+
64
+ def service_for(binding:, type:)
65
+ binding = Saml::Kit::Namespaces.binding_for(binding)
66
+ services(type).find { |x| x.binding?(binding) }
67
+ end
68
+
69
+ def single_logout_services
70
+ services('SingleLogoutService')
71
+ end
72
+
73
+ def single_logout_service_for(binding:)
74
+ service_for(binding: binding, type: 'SingleLogoutService')
75
+ end
76
+
77
+ def matches?(fingerprint, use: :signing)
78
+ if :signing == use.to_sym
79
+ hash_value = fingerprint.algorithm(hash_algorithm)
80
+ signing_certificates.find do |signing_certificate|
81
+ hash_value == signing_certificate[:fingerprint]
82
+ end
83
+ end
84
+ end
85
+
86
+ def to_h
87
+ @xml_hash ||= Hash.from_xml(to_xml)
88
+ end
89
+
90
+ def to_xml
91
+ @xml
92
+ end
93
+
94
+ def to_s
95
+ to_xml
96
+ end
97
+
98
+ def verify(algorithm, signature, data)
99
+ signing_certificates.find do |cert|
100
+ x509 = OpenSSL::X509::Certificate.new(Base64.decode64(cert[:text]))
101
+ public_key = x509.public_key
102
+ public_key.verify(algorithm, signature, data)
103
+ end
104
+ end
105
+
106
+ def self.from(content)
107
+ hash = Hash.from_xml(content)
108
+ entity_descriptor = hash["EntityDescriptor"]
109
+ if entity_descriptor.keys.include?("SPSSODescriptor")
110
+ Saml::Kit::ServiceProviderMetadata.new(content)
111
+ elsif entity_descriptor.keys.include?("IDPSSODescriptor")
112
+ Saml::Kit::IdentityProviderMetadata.new(content)
113
+ end
114
+ end
115
+
116
+ private
117
+
118
+ def document
119
+ @document ||= Nokogiri::XML(@xml)
120
+ end
121
+
122
+ def find_by(xpath)
123
+ document.at_xpath(xpath, NAMESPACES)
124
+ end
125
+
126
+ def find_all(xpath)
127
+ document.search(xpath, NAMESPACES)
128
+ end
129
+
130
+ def metadata
131
+ find_by("/md:EntityDescriptor/md:#{name}").present?
132
+ end
133
+
134
+ def must_contain_descriptor
135
+ errors[:base] << error_message(:invalid) unless metadata
136
+ end
137
+
138
+ def must_match_xsd
139
+ matches_xsd?(METADATA_XSD)
140
+ end
141
+
142
+ def must_have_valid_signature
143
+ return if to_xml.blank?
144
+
145
+ unless valid_signature?
146
+ errors[:base] << error_message(:invalid_signature)
147
+ end
148
+ end
149
+
150
+ def valid_signature?
151
+ xml = Saml::Kit::Xml.new(to_xml)
152
+ result = xml.valid?
153
+ xml.errors.each do |error|
154
+ errors[:base] << error
155
+ end
156
+ result
157
+ end
158
+
159
+ def binding_for(binding, location)
160
+ case binding
161
+ when Namespaces::HTTP_REDIRECT
162
+ Saml::Kit::HttpRedirectBinding.new(location: location)
163
+ when Namespaces::POST
164
+ Saml::Kit::HttpPostBinding.new(location: location)
165
+ else
166
+ Saml::Kit::Binding.new(binding: binding, location: location)
167
+ end
168
+ end
169
+ end
170
+ end
171
+ end