spid 0.10.0 → 0.11.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.
@@ -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