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.
- checksums.yaml +4 -4
- data/.rubocop.yml +3 -0
- data/CHANGELOG.md +6 -1
- data/Gemfile +0 -6
- data/README.md +11 -14
- data/lib/spid.rb +16 -10
- data/lib/spid/configuration.rb +27 -19
- data/lib/spid/identity_provider_manager.rb +14 -4
- data/lib/spid/metadata.rb +10 -77
- data/lib/spid/rack/login.rb +1 -1
- data/lib/spid/rack/logout.rb +1 -1
- data/lib/spid/saml2.rb +17 -0
- data/lib/spid/saml2/authn_request.rb +104 -0
- data/lib/spid/saml2/identity_provider.rb +27 -0
- data/lib/spid/saml2/idp_metadata_parser.rb +283 -0
- data/lib/spid/saml2/logout_request.rb +88 -0
- data/lib/spid/saml2/logout_response.rb +33 -0
- data/lib/spid/saml2/response.rb +58 -0
- data/lib/spid/saml2/service_provider.rb +78 -0
- data/lib/spid/saml2/settings.rb +85 -0
- data/lib/spid/saml2/sp_metadata.rb +104 -0
- data/lib/spid/saml2/utils.rb +62 -0
- data/lib/spid/saml2/utils/query_params_signer.rb +75 -0
- data/lib/spid/slo.rb +0 -1
- data/lib/spid/slo/request.rb +29 -20
- data/lib/spid/slo/response.rb +5 -32
- data/lib/spid/sso.rb +0 -1
- data/lib/spid/sso/request.rb +26 -19
- data/lib/spid/sso/response.rb +9 -30
- data/lib/spid/version.rb +1 -1
- data/spid.gemspec +1 -1
- metadata +28 -28
- data/lib/spid/authn_request.rb +0 -28
- data/lib/spid/identity_provider.rb +0 -60
- data/lib/spid/logout_request.rb +0 -21
- data/lib/spid/service_provider.rb +0 -107
- data/lib/spid/slo/settings.rb +0 -53
- data/lib/spid/sso/settings.rb +0 -62
@@ -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
|