spid 0.10.0 → 0.11.0
Sign up to get free protection for your applications and to get access to all the features.
- 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
|