spid 0.10.0 → 0.11.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Spid
4
+ module Saml2
5
+ class IdentityProvider # :nodoc:
6
+ attr_reader :name
7
+ attr_reader :entity_id
8
+ attr_reader :sso_target_url
9
+ attr_reader :slo_target_url
10
+ attr_reader :cert_fingerprint
11
+
12
+ def initialize(
13
+ name:,
14
+ entity_id:,
15
+ sso_target_url:,
16
+ slo_target_url:,
17
+ cert_fingerprint:
18
+ )
19
+ @name = name
20
+ @entity_id = entity_id
21
+ @sso_target_url = sso_target_url
22
+ @slo_target_url = slo_target_url
23
+ @cert_fingerprint = cert_fingerprint
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,283 @@
1
+ require "base64"
2
+ require "zlib"
3
+ require "cgi"
4
+ require "net/http"
5
+ require "net/https"
6
+ require "rexml/document"
7
+ require "rexml/xpath"
8
+
9
+ # Only supports SAML 2.0
10
+ module Spid
11
+ module Saml2
12
+ include REXML
13
+
14
+ # Auxiliary class to retrieve and parse the Identity Provider Metadata
15
+ #
16
+ class IdpMetadataParser
17
+
18
+ METADATA = "urn:oasis:names:tc:SAML:2.0:metadata"
19
+ DSIG = "http://www.w3.org/2000/09/xmldsig#"
20
+ NAME_FORMAT = "urn:oasis:names:tc:SAML:2.0:attrname-format:*"
21
+ SAML_ASSERTION = "urn:oasis:names:tc:SAML:2.0:assertion"
22
+
23
+ attr_reader :document
24
+ attr_reader :response
25
+ attr_reader :options
26
+
27
+ # Parse the Identity Provider metadata and return the results as Hash
28
+ #
29
+ # @param idp_metadata [String]
30
+ #
31
+ # @param options [Hash] options used for parsing the metadata and the returned Settings instance
32
+ # @option options [Array<String>, nil] :sso_binding an ordered list of bindings to detect the single signon URL. The first binding in the list that is included in the metadata will be used.
33
+ # @option options [Array<String>, nil] :slo_binding an ordered list of bindings to detect the single logout URL. The first binding in the list that is included in the metadata will be used.
34
+ # @option options [String, nil] :entity_id when this is given, the entity descriptor for this ID is used. When ommitted, the first entity descriptor is used.
35
+ #
36
+ # @return [Hash]
37
+ def parse_to_hash(idp_metadata, options = {})
38
+ @document = REXML::Document.new(idp_metadata)
39
+ @options = options
40
+ @entity_descriptor = nil
41
+ @certificates = nil
42
+ @fingerprint = nil
43
+
44
+ if idpsso_descriptor.nil?
45
+ raise ArgumentError.new("idp_metadata must contain an IDPSSODescriptor element")
46
+ end
47
+
48
+ {
49
+ :idp_entity_id => idp_entity_id,
50
+ :name_identifier_format => idp_name_id_format,
51
+ :idp_sso_target_url => single_signon_service_url(options),
52
+ :idp_slo_target_url => single_logout_service_url(options),
53
+ :idp_attribute_names => attribute_names,
54
+ :idp_cert => nil,
55
+ :idp_cert_fingerprint => nil,
56
+ :idp_cert_multi => nil
57
+ }.tap do |response_hash|
58
+ merge_certificates_into(response_hash) unless certificates.nil?
59
+ end
60
+ end
61
+
62
+ private
63
+
64
+ def entity_descriptor
65
+ @entity_descriptor ||= REXML::XPath.first(
66
+ document,
67
+ entity_descriptor_path,
68
+ namespace
69
+ )
70
+ end
71
+
72
+ def entity_descriptor_path
73
+ path = "//md:EntityDescriptor"
74
+ entity_id = options[:entity_id]
75
+ return path unless entity_id
76
+ path << "[@entityID=\"#{entity_id}\"]"
77
+ end
78
+
79
+ def idpsso_descriptor
80
+ unless entity_descriptor.nil?
81
+ return REXML::XPath.first(
82
+ entity_descriptor,
83
+ "md:IDPSSODescriptor",
84
+ namespace
85
+ )
86
+ end
87
+ end
88
+
89
+ # @return [String|nil] IdP Entity ID value if exists
90
+ #
91
+ def idp_entity_id
92
+ entity_descriptor.attributes["entityID"]
93
+ end
94
+
95
+ # @return [String|nil] IdP Name ID Format value if exists
96
+ #
97
+ def idp_name_id_format
98
+ node = REXML::XPath.first(
99
+ entity_descriptor,
100
+ "md:IDPSSODescriptor/md:NameIDFormat",
101
+ namespace
102
+ )
103
+ element_text(node)
104
+ end
105
+
106
+ # @param binding_priority [Array]
107
+ # @return [String|nil] SingleSignOnService binding if exists
108
+ #
109
+ def single_signon_service_binding(binding_priority = nil)
110
+ nodes = REXML::XPath.match(
111
+ entity_descriptor,
112
+ "md:IDPSSODescriptor/md:SingleSignOnService/@Binding",
113
+ namespace
114
+ )
115
+ if binding_priority
116
+ values = nodes.map(&:value)
117
+ binding_priority.detect{ |binding| values.include? binding }
118
+ else
119
+ nodes.first.value if nodes.any?
120
+ end
121
+ end
122
+
123
+ # @param options [Hash]
124
+ # @return [String|nil] SingleSignOnService endpoint if exists
125
+ #
126
+ def single_signon_service_url(options = {})
127
+ binding = single_signon_service_binding(options[:sso_binding])
128
+ unless binding.nil?
129
+ node = REXML::XPath.first(
130
+ entity_descriptor,
131
+ "md:IDPSSODescriptor/md:SingleSignOnService[@Binding=\"#{binding}\"]/@Location",
132
+ namespace
133
+ )
134
+ return node.value if node
135
+ end
136
+ end
137
+
138
+ # @param binding_priority [Array]
139
+ # @return [String|nil] SingleLogoutService binding if exists
140
+ #
141
+ def single_logout_service_binding(binding_priority = nil)
142
+ nodes = REXML::XPath.match(
143
+ entity_descriptor,
144
+ "md:IDPSSODescriptor/md:SingleLogoutService/@Binding",
145
+ namespace
146
+ )
147
+ if binding_priority
148
+ values = nodes.map(&:value)
149
+ binding_priority.detect{ |binding| values.include? binding }
150
+ else
151
+ nodes.first.value if nodes.any?
152
+ end
153
+ end
154
+
155
+ # @param options [Hash]
156
+ # @return [String|nil] SingleLogoutService endpoint if exists
157
+ #
158
+ def single_logout_service_url(options = {})
159
+ binding = single_logout_service_binding(options[:slo_binding])
160
+ unless binding.nil?
161
+ node = REXML::XPath.first(
162
+ entity_descriptor,
163
+ "md:IDPSSODescriptor/md:SingleLogoutService[@Binding=\"#{binding}\"]/@Location",
164
+ namespace
165
+ )
166
+ return node.value if node
167
+ end
168
+ end
169
+
170
+ # @return [String|nil] Unformatted Certificate if exists
171
+ #
172
+ def certificates
173
+ @certificates ||= begin
174
+ signing_nodes = REXML::XPath.match(
175
+ entity_descriptor,
176
+ "md:IDPSSODescriptor/md:KeyDescriptor[not(contains(@use, 'encryption'))]/ds:KeyInfo/ds:X509Data/ds:X509Certificate",
177
+ namespace
178
+ )
179
+
180
+ encryption_nodes = REXML::XPath.match(
181
+ entity_descriptor,
182
+ "md:IDPSSODescriptor/md:KeyDescriptor[not(contains(@use, 'signing'))]/ds:KeyInfo/ds:X509Data/ds:X509Certificate",
183
+ namespace
184
+ )
185
+
186
+ certs = nil
187
+ unless signing_nodes.empty? && encryption_nodes.empty?
188
+ certs = {}
189
+ unless signing_nodes.empty?
190
+ certs['signing'] = []
191
+ signing_nodes.each do |cert_node|
192
+ certs['signing'] << element_text(cert_node)
193
+ end
194
+ end
195
+
196
+ unless encryption_nodes.empty?
197
+ certs['encryption'] = []
198
+ encryption_nodes.each do |cert_node|
199
+ certs['encryption'] << element_text(cert_node)
200
+ end
201
+ end
202
+ end
203
+ certs
204
+ end
205
+ end
206
+
207
+ # @return [String|nil] the fingerpint of the X509Certificate if it exists
208
+ #
209
+ def fingerprint(certificate, fingerprint_algorithm = ::Spid::SHA256)
210
+ @fingerprint ||= begin
211
+ if certificate
212
+ cert = OpenSSL::X509::Certificate.new(Base64.decode64(certificate))
213
+
214
+ algorithm = fingerprint_algorithm || ::Spid::SHA256
215
+ fingerprint_alg = ::Spid::SIGNATURE_ALGORITHMS[algorithm]
216
+ fingerprint_alg.hexdigest(cert.to_der).upcase.scan(/../).join(":")
217
+ end
218
+ end
219
+ end
220
+
221
+ # @return [Array] the names of all SAML attributes if any exist
222
+ #
223
+ def attribute_names
224
+ nodes = REXML::XPath.match(
225
+ entity_descriptor,
226
+ "md:IDPSSODescriptor/saml:Attribute/@Name",
227
+ namespace
228
+ )
229
+ nodes.map(&:value)
230
+ end
231
+
232
+ def namespace
233
+ {
234
+ "md" => METADATA,
235
+ "NameFormat" => NAME_FORMAT,
236
+ "saml" => SAML_ASSERTION,
237
+ "ds" => DSIG
238
+ }
239
+ end
240
+
241
+ def merge_certificates_into(parsed_metadata)
242
+ if (certificates.size == 1 &&
243
+ (certificates_has_one('signing') || certificates_has_one('encryption'))) ||
244
+ (certificates_has_one('signing') && certificates_has_one('encryption') &&
245
+ certificates["signing"][0] == certificates["encryption"][0])
246
+
247
+ if certificates.key?("signing")
248
+ parsed_metadata[:idp_cert] = certificates["signing"][0]
249
+ parsed_metadata[:idp_cert_fingerprint] = fingerprint(
250
+ parsed_metadata[:idp_cert],
251
+ parsed_metadata[:idp_cert_fingerprint_algorithm]
252
+ )
253
+ else
254
+ parsed_metadata[:idp_cert] = certificates["encryption"][0]
255
+ parsed_metadata[:idp_cert_fingerprint] = fingerprint(
256
+ parsed_metadata[:idp_cert],
257
+ parsed_metadata[:idp_cert_fingerprint_algorithm]
258
+ )
259
+ end
260
+ else
261
+ # symbolize keys of certificates and pass it on
262
+ parsed_metadata[:idp_cert_multi] = Hash[certificates.map { |k, v| [k.to_sym, v] }]
263
+ end
264
+ end
265
+
266
+ def certificates_has_one(key)
267
+ certificates.key?(key) && certificates[key].size == 1
268
+ end
269
+
270
+ def merge_parsed_metadata_into(settings, parsed_metadata)
271
+ parsed_metadata.each do |key, value|
272
+ settings.send("#{key}=".to_sym, value)
273
+ end
274
+
275
+ settings
276
+ end
277
+
278
+ def element_text(element)
279
+ element.texts.map(&:value).join if element
280
+ end
281
+ end
282
+ end
283
+ end
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Spid
4
+ module Saml2
5
+ class LogoutRequest # :nodoc:
6
+ attr_reader :settings
7
+ attr_reader :uuid
8
+ attr_reader :document
9
+ attr_reader :session_index
10
+
11
+ def initialize(uuid: nil, settings:, session_index:)
12
+ @settings = settings
13
+ @document = REXML::Document.new
14
+ @session_index = session_index
15
+ @uuid = uuid || SecureRandom.uuid
16
+ end
17
+
18
+ def to_saml
19
+ document.add_element(logout_request)
20
+ document.to_s
21
+ end
22
+
23
+ def logout_request
24
+ @logout_request ||=
25
+ begin
26
+ element = REXML::Element.new("samlp:LogoutRequest")
27
+ element.add_attributes(logout_request_attributes)
28
+ element.add_element(issuer)
29
+ element.add_element(name_id)
30
+ element.add_element(samlp_session_index)
31
+ element
32
+ end
33
+ end
34
+
35
+ def logout_request_attributes
36
+ @logout_request_attributes ||= {
37
+ "xmlns:samlp" => "urn:oasis:names:tc:SAML:2.0:protocol",
38
+ "xmlns:saml" => "urn:oasis:names:tc:SAML:2.0:assertion",
39
+ "ID" => "_#{uuid}",
40
+ "Version" => "2.0",
41
+ "IssueInstant" => issue_instant,
42
+ "Destination" => settings.idp_slo_target_url
43
+ }
44
+ end
45
+
46
+ def issuer
47
+ @issuer ||=
48
+ begin
49
+ element = REXML::Element.new("saml:Issuer")
50
+ element.add_attributes(
51
+ "Format" => "urn:oasis:names:tc:SAML:2.0:nameid-format:entity",
52
+ "NameQualifier" => settings.sp_entity_id
53
+ )
54
+ element.text = settings.sp_entity_id
55
+ element
56
+ end
57
+ end
58
+
59
+ def name_id
60
+ @name_id ||=
61
+ begin
62
+ element = REXML::Element.new("saml:NameID")
63
+ element.add_attributes(
64
+ "Format" => "urn:oasis:names:tc:SAML:2.0:nameid-format:transient",
65
+ "NameQualifier" => settings.idp_entity_id
66
+ )
67
+ element.text = "a-name-identifier-value"
68
+ element
69
+ end
70
+ end
71
+
72
+ def samlp_session_index
73
+ @samlp_session_index ||=
74
+ begin
75
+ element = REXML::Element.new("samlp:SessionIndex")
76
+ element.text = session_index
77
+ element
78
+ end
79
+ end
80
+
81
+ private
82
+
83
+ def issue_instant
84
+ @issue_instant ||= Time.now.utc.iso8601
85
+ end
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spid/saml2/utils"
4
+
5
+ module Spid
6
+ module Saml2
7
+ class LogoutResponse # :nodoc:
8
+ include Spid::Saml2::Utils
9
+
10
+ attr_reader :body
11
+ attr_reader :saml_message
12
+ attr_reader :document
13
+
14
+ def initialize(body:)
15
+ @body = body
16
+ @saml_message = decode_and_inflate(body)
17
+ @document = REXML::Document.new(@saml_message)
18
+ end
19
+
20
+ def issuer
21
+ document.elements[
22
+ "/samlp:LogoutResponse/saml:Issuer/text()"
23
+ ]&.value&.strip
24
+ end
25
+
26
+ def in_response_to
27
+ document.elements[
28
+ "/samlp:LogoutResponse/@InResponseTo"
29
+ ]&.value&.strip
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spid/saml2/utils"
4
+
5
+ module Spid
6
+ module Saml2
7
+ class Response # :nodoc:
8
+ include Spid::Saml2::Utils
9
+
10
+ attr_reader :body
11
+ attr_reader :saml_message
12
+ attr_reader :document
13
+
14
+ def initialize(body:)
15
+ @body = body
16
+ @saml_message = decode_and_inflate(body)
17
+ @document = REXML::Document.new(@saml_message)
18
+ end
19
+
20
+ def issuer
21
+ document.elements["/samlp:Response/saml:Issuer/text()"]&.value
22
+ end
23
+
24
+ def assertion_issuer
25
+ document.elements[
26
+ "/samlp:Response/saml:Assertion/saml:Issuer/text()"
27
+ ]&.value
28
+ end
29
+
30
+ def session_index
31
+ document.elements[
32
+ "/samlp:Response/saml:Assertion/saml:AuthnStatement/@SessionIndex"
33
+ ]&.value
34
+ end
35
+
36
+ def destination
37
+ document.elements[
38
+ "/samlp:Response/@Destination"
39
+ ]&.value
40
+ end
41
+
42
+ def attributes
43
+ main_xpath = "/samlp:Response/saml:Assertion/saml:AttributeStatement"
44
+ main_xpath = "#{main_xpath}/saml:Attribute"
45
+
46
+ attributes = REXML::XPath.match(document, main_xpath)
47
+ attributes.each_with_object({}) do |attribute, acc|
48
+ xpath = attribute.xpath
49
+
50
+ name = document.elements["#{xpath}/@Name"].value
51
+ value = document.elements["#{xpath}/saml:AttributeValue/text()"].value
52
+
53
+ acc[name] = value
54
+ end
55
+ end
56
+ end
57
+ end
58
+ end