Skip to content

Commit 58dedaf

Browse files
authored
Merge pull request SAML-Toolkits#393 from tosch/Parse_idp_metadata_into_an_hash
Implement IdpMetadataParser#parse_to_hash
2 parents 319d5ec + ee0ce5b commit 58dedaf

File tree

3 files changed

+247
-40
lines changed

3 files changed

+247
-40
lines changed

README.md

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -281,7 +281,7 @@ And on 'signing' and 'encryption' arrays, add the different IdP x509 public cert
281281
The method above requires a little extra work to manually specify attributes about the IdP. (And your SP application) There's an easier method -- use a metadata exchange. Metadata is just an XML file that defines the capabilities of both the IdP and the SP application. It also contains the X.509 public
282282
key certificates which add to the trusted relationship. The IdP administrator can also configure custom settings for an SP based on the metadata.
283283
284-
Using ```idp_metadata_parser.parse_remote``` IdP metadata will be added to the settings withouth further ado.
284+
Using ```idp_metadata_parser.parse_remote``` IdP metadata will be added to the settings without further ado.
285285
286286
```ruby
287287
def saml_settings
@@ -300,9 +300,14 @@ def saml_settings
300300
end
301301
```
302302
The following attributes are set:
303+
* idp_entity_id
304+
* name_identifier_format
303305
* idp_sso_target_url
304306
* idp_slo_target_url
305-
* idp_cert_fingerprint
307+
* idp_attribute_names
308+
* idp_cert
309+
* idp_cert_fingerprint
310+
* idp_cert_multi
306311
307312
### Retrieve one Entity Descriptor when many exist in Metadata
308313
@@ -319,6 +324,12 @@ IdpMetadataParser by its Entity Id value:
319324
)
320325
```
321326
327+
### Parsing Metadata into an Hash
328+
329+
The `OneLogin::RubySaml::IdpMetadataParser` also provides the methods `#parse_to_hash` and `#parse_remote_to_hash`.
330+
Those return an Hash instead of a `Settings` object, which may be useful for configuring
331+
[omniauth-saml](https://github.com/omniauth/omniauth-saml), for instance.
332+
322333
## Retrieving Attributes
323334
324335
If you are using `saml:AttributeStatement` to transfer data like the username, you can access all the attributes through `response.attributes`. It contains all the `saml:AttributeStatement`s with its 'Name' as an indifferent key and one or more `saml:AttributeValue`s as values. The value returned depends on the value of the

lib/onelogin/ruby-saml/idp_metadata_parser.rb

Lines changed: 111 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -22,57 +22,98 @@ class IdpMetadataParser
2222

2323
attr_reader :document
2424
attr_reader :response
25-
attr_reader :parse_options
25+
attr_reader :options
2626

2727
# Parse the Identity Provider metadata and update the settings with the
2828
# IdP values
2929
#
30-
# @param (see IdpMetadataParser#get_idp_metadata)
31-
# @param options [Hash] :settings to provide the OneLogin::RubySaml::Settings object or an hash for Settings overrides
32-
# @return (see IdpMetadataParser#get_idp_metadata)
33-
# @raise (see IdpMetadataParser#get_idp_metadata)
30+
# @param url [String] Url where the XML of the Identity Provider Metadata is published.
31+
# @param validate_cert [Boolean] If true and the URL is HTTPs, the cert of the domain is checked.
32+
#
33+
# @param options [Hash] options used for parsing the metadata and the returned Settings instance
34+
# @option options [OneLogin::RubySaml::Settings, Hash] :settings the OneLogin::RubySaml::Settings object which gets the parsed metadata merged into or an hash for Settings overrides.
35+
# @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.
36+
# @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.
37+
# @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.
38+
#
39+
# @return [OneLogin::RubySaml::Settings]
40+
#
41+
# @raise [HttpError] Failure to fetch remote IdP metadata
3442
def parse_remote(url, validate_cert = true, options = {})
3543
idp_metadata = get_idp_metadata(url, validate_cert)
3644
parse(idp_metadata, options)
3745
end
3846

47+
# Parse the Identity Provider metadata and return the results as Hash
48+
#
49+
# @param url [String] Url where the XML of the Identity Provider Metadata is published.
50+
# @param validate_cert [Boolean] If true and the URL is HTTPs, the cert of the domain is checked.
51+
#
52+
# @param options [Hash] options used for parsing the metadata
53+
# @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.
54+
# @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.
55+
# @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.
56+
#
57+
# @return [Hash]
58+
#
59+
# @raise [HttpError] Failure to fetch remote IdP metadata
60+
def parse_remote_to_hash(url, validate_cert = true, options = {})
61+
idp_metadata = get_idp_metadata(url, validate_cert)
62+
parse_to_hash(idp_metadata, options)
63+
end
64+
3965
# Parse the Identity Provider metadata and update the settings with the IdP values
66+
#
4067
# @param idp_metadata [String]
41-
# @param options [Hash] :settings to provide the OneLogin::RubySaml::Settings object or an hash for Settings overrides
4268
#
43-
def parse(idp_metadata, parse_options = {})
44-
@document = REXML::Document.new(idp_metadata)
45-
@parse_options = parse_options
46-
@entity_descriptor = nil
69+
# @param options [Hash] :settings to provide the OneLogin::RubySaml::Settings object or an hash for Settings overrides
70+
# @option options [OneLogin::RubySaml::Settings, Hash] :settings the OneLogin::RubySaml::Settings object which gets the parsed metadata merged into or an hash for Settings overrides.
71+
# @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.
72+
# @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.
73+
# @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.
74+
#
75+
# @return [OneLogin::RubySaml::Settings]
76+
def parse(idp_metadata, options = {})
77+
parsed_metadata = parse_to_hash(idp_metadata, options)
78+
79+
settings = options[:settings]
4780

48-
settings = parse_options[:settings]
49-
if settings.nil? || settings.is_a?(Hash)
50-
settings = OneLogin::RubySaml::Settings.new(settings || {})
81+
if settings.nil?
82+
OneLogin::RubySaml::Settings.new(parsed_metadata)
83+
elsif settings.is_a?(Hash)
84+
OneLogin::RubySaml::Settings.new(settings.merge(parsed_metadata))
85+
else
86+
merge_parsed_metadata_into(settings, parsed_metadata)
5187
end
88+
end
5289

53-
settings.idp_entity_id = idp_entity_id
54-
settings.name_identifier_format = idp_name_id_format
55-
settings.idp_sso_target_url = single_signon_service_url(parse_options)
56-
settings.idp_slo_target_url = single_logout_service_url(parse_options)
57-
settings.idp_attribute_names = attribute_names
58-
59-
settings.idp_cert = nil
60-
settings.idp_cert_fingerprint = nil
61-
settings.idp_cert_multi = nil
62-
unless certificates.nil?
63-
if certificates.size == 1 || ((certificates.key?("signing") && certificates["signing"].size == 1) && (certificates.key?("encryption") && certificates["encryption"].size == 1) && certificates["signing"][0] == certificates["encryption"][0])
64-
if certificates.key?("signing")
65-
settings.idp_cert = certificates["signing"][0]
66-
settings.idp_cert_fingerprint = fingerprint(settings.idp_cert, settings.idp_cert_fingerprint_algorithm)
67-
else
68-
settings.idp_cert = certificates["encryption"][0]
69-
settings.idp_cert_fingerprint = fingerprint(settings.idp_cert, settings.idp_cert_fingerprint_algorithm)
70-
end
71-
else
72-
settings.idp_cert_multi = certificates
73-
end
90+
# Parse the Identity Provider metadata and return the results as Hash
91+
#
92+
# @param idp_metadata [String]
93+
#
94+
# @param options [Hash] options used for parsing the metadata and the returned Settings instance
95+
# @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.
96+
# @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.
97+
# @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.
98+
#
99+
# @return [Hash]
100+
def parse_to_hash(idp_metadata, options = {})
101+
@document = REXML::Document.new(idp_metadata)
102+
@options = options
103+
@entity_descriptor = nil
104+
105+
{
106+
:idp_entity_id => idp_entity_id,
107+
:name_identifier_format => idp_name_id_format,
108+
:idp_sso_target_url => single_signon_service_url(options),
109+
:idp_slo_target_url => single_logout_service_url(options),
110+
:idp_attribute_names => attribute_names,
111+
:idp_cert => nil,
112+
:idp_cert_fingerprint => nil,
113+
:idp_cert_multi => nil
114+
}.tap do |response_hash|
115+
merge_certificates_into(response_hash) unless certificates.nil?
74116
end
75-
settings
76117
end
77118

78119
private
@@ -118,7 +159,7 @@ def entity_descriptor
118159

119160
def entity_descriptor_path
120161
path = "//md:EntityDescriptor"
121-
entity_id = parse_options[:entity_id]
162+
entity_id = options[:entity_id]
122163
return path unless entity_id
123164
path << "[@entityID=\"#{entity_id}\"]"
124165
end
@@ -162,7 +203,7 @@ def single_signon_service_binding(binding_priority = nil)
162203
#
163204
def single_signon_service_url(options = {})
164205
binding = single_signon_service_binding(options[:sso_binding])
165-
unless binding.nil?
206+
unless binding.nil?
166207
node = REXML::XPath.first(
167208
entity_descriptor,
168209
"md:IDPSSODescriptor/md:SingleSignOnService[@Binding=\"#{binding}\"]/@Location",
@@ -222,7 +263,7 @@ def certificates
222263

223264
certs = nil
224265
unless signing_nodes.empty? && encryption_nodes.empty?
225-
certs = {}
266+
certs = {}
226267
unless signing_nodes.empty?
227268
certs['signing'] = []
228269
signing_nodes.each do |cert_node|
@@ -273,6 +314,38 @@ def namespace
273314
"ds" => DSIG
274315
}
275316
end
317+
318+
def merge_certificates_into(parsed_metadata)
319+
if certificates.size == 1 ||
320+
((certificates.key?("signing") && certificates["signing"].size == 1) &&
321+
(certificates.key?("encryption") && certificates["encryption"].size == 1) &&
322+
certificates["signing"][0] == certificates["encryption"][0])
323+
324+
if certificates.key?("signing")
325+
parsed_metadata[:idp_cert] = certificates["signing"][0]
326+
parsed_metadata[:idp_cert_fingerprint] = fingerprint(
327+
parsed_metadata[:idp_cert],
328+
parsed_metadata[:idp_cert_fingerprint_algorithm]
329+
)
330+
else
331+
parsed_metadata[:idp_cert] = certificates["encryption"][0]
332+
parsed_metadata[:idp_cert_fingerprint] = fingerprint(
333+
parsed_metadata[:idp_cert],
334+
parsed_metadata[:idp_cert_fingerprint_algorithm]
335+
)
336+
end
337+
else
338+
parsed_metadata[:idp_cert_multi] = certificates
339+
end
340+
end
341+
342+
def merge_parsed_metadata_into(settings, parsed_metadata)
343+
parsed_metadata.each do |key, value|
344+
settings.send("#{key}=".to_sym, value)
345+
end
346+
347+
settings
348+
end
276349
end
277350
end
278351
end

test/idp_metadata_parser_test.rb

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,96 @@ def initialize; end
9595
assert_equal XMLSecurity::Document::RSA_SHA256, settings.security[:signature_method]
9696
end
9797

98+
it "merges results into given settings object" do
99+
settings = OneLogin::RubySaml::Settings.new(:security => {
100+
:digest_method => XMLSecurity::Document::SHA256,
101+
:signature_method => XMLSecurity::Document::RSA_SHA256
102+
})
103+
104+
OneLogin::RubySaml::IdpMetadataParser.new.parse(idp_metadata_descriptor, :settings => settings)
105+
106+
assert_equal "F1:3C:6B:80:90:5A:03:0E:6C:91:3E:5D:15:FA:DD:B0:16:45:48:72", settings.idp_cert_fingerprint
107+
assert_equal XMLSecurity::Document::SHA256, settings.security[:digest_method]
108+
assert_equal XMLSecurity::Document::RSA_SHA256, settings.security[:signature_method]
109+
end
110+
end
111+
112+
describe "parsing an IdP descriptor file into an Hash" do
113+
it "extract settings details from xml" do
114+
idp_metadata_parser = OneLogin::RubySaml::IdpMetadataParser.new
115+
116+
metadata = idp_metadata_parser.parse_to_hash(idp_metadata_descriptor)
117+
118+
assert_equal "https://hello.example.com/access/saml/idp.xml", metadata[:idp_entity_id]
119+
assert_equal "https://hello.example.com/access/saml/login", metadata[:idp_sso_target_url]
120+
assert_equal "F1:3C:6B:80:90:5A:03:0E:6C:91:3E:5D:15:FA:DD:B0:16:45:48:72", metadata[:idp_cert_fingerprint]
121+
assert_equal "https://hello.example.com/access/saml/logout", metadata[:idp_slo_target_url]
122+
assert_equal "urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified", metadata[:name_identifier_format]
123+
assert_equal ["AuthToken", "SSOStartPage"], metadata[:idp_attribute_names]
124+
end
125+
126+
it "extract certificate from md:KeyDescriptor[@use='signing']" do
127+
idp_metadata_parser = OneLogin::RubySaml::IdpMetadataParser.new
128+
idp_metadata = idp_metadata_descriptor
129+
metadata = idp_metadata_parser.parse_to_hash(idp_metadata)
130+
assert_equal "F1:3C:6B:80:90:5A:03:0E:6C:91:3E:5D:15:FA:DD:B0:16:45:48:72", metadata[:idp_cert_fingerprint]
131+
end
132+
133+
it "extract certificate from md:KeyDescriptor[@use='encryption']" do
134+
idp_metadata_parser = OneLogin::RubySaml::IdpMetadataParser.new
135+
idp_metadata = idp_metadata_descriptor
136+
idp_metadata = idp_metadata.sub(/<md:KeyDescriptor use="signing">(.*?)<\/md:KeyDescriptor>/m, "")
137+
parsed_metadata = idp_metadata_parser.parse_to_hash(idp_metadata)
138+
assert_equal "F1:3C:6B:80:90:5A:03:0E:6C:91:3E:5D:15:FA:DD:B0:16:45:48:72", parsed_metadata[:idp_cert_fingerprint]
139+
end
140+
141+
it "extract certificate from md:KeyDescriptor" do
142+
idp_metadata_parser = OneLogin::RubySaml::IdpMetadataParser.new
143+
idp_metadata = idp_metadata_descriptor
144+
idp_metadata = idp_metadata.sub(/<md:KeyDescriptor use="signing">(.*?)<\/md:KeyDescriptor>/m, "")
145+
idp_metadata = idp_metadata.sub('<md:KeyDescriptor use="encryption">', '<md:KeyDescriptor>')
146+
parsed_metadata = idp_metadata_parser.parse_to_hash(idp_metadata)
147+
assert_equal "F1:3C:6B:80:90:5A:03:0E:6C:91:3E:5D:15:FA:DD:B0:16:45:48:72", parsed_metadata[:idp_cert_fingerprint]
148+
end
149+
150+
it "extract SSO endpoint with no specific binding, it takes the first" do
151+
idp_metadata_parser = OneLogin::RubySaml::IdpMetadataParser.new
152+
idp_metadata = idp_metadata_descriptor3
153+
metadata = idp_metadata_parser.parse_to_hash(idp_metadata)
154+
assert_equal "https://idp.example.com/idp/profile/Shibboleth/SSO", metadata[:idp_sso_target_url]
155+
end
156+
157+
it "extract SSO endpoint with specific binding" do
158+
idp_metadata_parser = OneLogin::RubySaml::IdpMetadataParser.new
159+
idp_metadata = idp_metadata_descriptor3
160+
options = {}
161+
options[:sso_binding] = ['urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST']
162+
parsed_metadata = idp_metadata_parser.parse_to_hash(idp_metadata, options)
163+
assert_equal "https://idp.example.com/idp/profile/SAML2/POST/SSO", parsed_metadata[:idp_sso_target_url]
164+
165+
options[:sso_binding] = ['urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect']
166+
parsed_metadata = idp_metadata_parser.parse_to_hash(idp_metadata, options)
167+
assert_equal "https://idp.example.com/idp/profile/SAML2/Redirect/SSO", parsed_metadata[:idp_sso_target_url]
168+
169+
options[:sso_binding] = ['invalid_binding', 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect']
170+
parsed_metadata = idp_metadata_parser.parse_to_hash(idp_metadata, options)
171+
assert_equal "https://idp.example.com/idp/profile/SAML2/Redirect/SSO", parsed_metadata[:idp_sso_target_url]
172+
end
173+
174+
it "ignores a given :settings hash" do
175+
idp_metadata_parser = OneLogin::RubySaml::IdpMetadataParser.new
176+
idp_metadata = idp_metadata_descriptor
177+
parsed_metadata = idp_metadata_parser.parse_to_hash(idp_metadata, {
178+
:settings => {
179+
:security => {
180+
:digest_method => XMLSecurity::Document::SHA256,
181+
:signature_method => XMLSecurity::Document::RSA_SHA256
182+
}
183+
}
184+
})
185+
assert_equal "F1:3C:6B:80:90:5A:03:0E:6C:91:3E:5D:15:FA:DD:B0:16:45:48:72", parsed_metadata[:idp_cert_fingerprint]
186+
assert_nil parsed_metadata[:security]
187+
end
98188
end
99189

100190
describe "parsing an IdP descriptor file with multiple signing certs" do
@@ -152,6 +242,39 @@ def initialize; end
152242
end
153243
end
154244

245+
describe "download and parse IdP descriptor file into an Hash" do
246+
before do
247+
mock_response = MockSuccessResponse.new
248+
mock_response.body = idp_metadata_descriptor
249+
@url = "https://example.com"
250+
uri = URI(@url)
251+
252+
@http = Net::HTTP.new(uri.host, uri.port)
253+
Net::HTTP.expects(:new).returns(@http)
254+
@http.expects(:request).returns(mock_response)
255+
end
256+
257+
it "extract settings from remote xml" do
258+
idp_metadata_parser = OneLogin::RubySaml::IdpMetadataParser.new
259+
parsed_metadata = idp_metadata_parser.parse_remote_to_hash(@url)
260+
261+
assert_equal "https://hello.example.com/access/saml/idp.xml", parsed_metadata[:idp_entity_id]
262+
assert_equal "https://hello.example.com/access/saml/login", parsed_metadata[:idp_sso_target_url]
263+
assert_equal "F1:3C:6B:80:90:5A:03:0E:6C:91:3E:5D:15:FA:DD:B0:16:45:48:72", parsed_metadata[:idp_cert_fingerprint]
264+
assert_equal "https://hello.example.com/access/saml/logout", parsed_metadata[:idp_slo_target_url]
265+
assert_equal "urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified", parsed_metadata[:name_identifier_format]
266+
assert_equal ["AuthToken", "SSOStartPage"], parsed_metadata[:idp_attribute_names]
267+
assert_equal OpenSSL::SSL::VERIFY_PEER, @http.verify_mode
268+
end
269+
270+
it "accept self signed certificate if insturcted" do
271+
idp_metadata_parser = OneLogin::RubySaml::IdpMetadataParser.new
272+
idp_metadata_parser.parse_remote_to_hash(@url, false)
273+
274+
assert_equal OpenSSL::SSL::VERIFY_NONE, @http.verify_mode
275+
end
276+
end
277+
155278
describe "download failure cases" do
156279
it "raises an exception when the url has no scheme" do
157280
idp_metadata_parser = OneLogin::RubySaml::IdpMetadataParser.new

0 commit comments

Comments
 (0)