Skip to content

Commit 10dc84f

Browse files
committed
IdP metadata parser/merger adjustments
1 parent 4c14943 commit 10dc84f

File tree

2 files changed

+399
-23
lines changed

2 files changed

+399
-23
lines changed

src/onelogin/saml2/idp_metadata_parser.py

Lines changed: 73 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@
1010
"""
1111

1212
import urllib2
13+
14+
from copy import deepcopy
1315
from defusedxml.lxml import fromstring
1416

1517
from onelogin.saml2.constants import OneLogin_Saml2_Constants
@@ -51,7 +53,7 @@ def get_metadata(url):
5153
return xml
5254

5355
@staticmethod
54-
def parse_remote(url):
56+
def parse_remote(url, **kwargs):
5557
"""
5658
Get the metadata XML from the provided URL and parse it, returning a dict with extracted data
5759
@@ -62,22 +64,41 @@ def parse_remote(url):
6264
:rtype: dict
6365
"""
6466
idp_metadata = OneLogin_Saml2_IdPMetadataParser.get_metadata(url)
65-
return OneLogin_Saml2_IdPMetadataParser.parse(idp_metadata)
67+
return OneLogin_Saml2_IdPMetadataParser.parse(idp_metadata, **kwargs)
6668

6769
@staticmethod
68-
def parse(idp_metadata):
70+
def parse(
71+
idp_metadata,
72+
required_sso_binding=OneLogin_Saml2_Constants.BINDING_HTTP_REDIRECT,
73+
required_slo_binding=OneLogin_Saml2_Constants.BINDING_HTTP_REDIRECT):
6974
"""
70-
Parse the Identity Provider metadata and returns a dict with extracted data
71-
If there are multiple IDPSSODescriptor it will only parse the first
75+
Parse the Identity Provider metadata and return a dict with extracted data.
76+
77+
If there are multiple <IDPSSODescriptor> tags, parse only the first.
78+
79+
Parse only those SSO endpoints with the same binding as given by
80+
the `required_sso_binding` parameter.
81+
82+
Parse only those SLO endpoints with the same binding as given by
83+
the `required_slo_binding` parameter.
84+
85+
If the metadata specifies multiple SSO endpoints with the required
86+
binding, extract only the first (the same holds true for SLO
87+
endpoints).
7288
7389
:param idp_metadata: XML of the Identity Provider Metadata.
7490
:type idp_metadata: string
7591
76-
:param url: If true and the URL is HTTPs, the cert of the domain is checked.
77-
:type url: bool
92+
:param required_sso_binding: Parse only POST or REDIRECT SSO endpoints.
93+
:type required_sso_binding: one of OneLogin_Saml2_Constants.BINDING_HTTP_REDIRECT
94+
or OneLogin_Saml2_Constants.BINDING_HTTP_POST
95+
96+
:param required_slo_binding: Parse only POST or REDIRECT SLO endpoints.
97+
:type required_slo_binding: one of OneLogin_Saml2_Constants.BINDING_HTTP_REDIRECT
98+
or OneLogin_Saml2_Constants.BINDING_HTTP_POST
7899
79100
:returns: settings dict with extracted data
80-
:rtype: string
101+
:rtype: dict
81102
"""
82103
data = {}
83104

@@ -100,11 +121,18 @@ def parse(idp_metadata):
100121
if len(name_id_format_nodes) > 0:
101122
idp_name_id_format = name_id_format_nodes[0].text
102123

103-
sso_nodes = OneLogin_Saml2_Utils.query(idp_descriptor_node, "./md:SingleSignOnService[@Binding='%s']" % OneLogin_Saml2_Constants.BINDING_HTTP_REDIRECT)
124+
sso_nodes = OneLogin_Saml2_Utils.query(
125+
idp_descriptor_node,
126+
"./md:SingleSignOnService[@Binding='%s']" % required_sso_binding
127+
)
128+
104129
if len(sso_nodes) > 0:
105130
idp_sso_url = sso_nodes[0].get('Location', None)
106131

107-
slo_nodes = OneLogin_Saml2_Utils.query(idp_descriptor_node, "./md:SingleLogoutService[@Binding='%s']" % OneLogin_Saml2_Constants.BINDING_HTTP_REDIRECT)
132+
slo_nodes = OneLogin_Saml2_Utils.query(
133+
idp_descriptor_node,
134+
"./md:SingleLogoutService[@Binding='%s']" % required_slo_binding
135+
)
108136
if len(slo_nodes) > 0:
109137
idp_slo_url = slo_nodes[0].get('Location', None)
110138

@@ -116,12 +144,17 @@ def parse(idp_metadata):
116144

117145
if idp_entity_id is not None:
118146
data['idp']['entityId'] = idp_entity_id
147+
119148
if idp_sso_url is not None:
120149
data['idp']['singleSignOnService'] = {}
121150
data['idp']['singleSignOnService']['url'] = idp_sso_url
151+
data['idp']['singleSignOnService']['binding'] = required_sso_binding
152+
122153
if idp_slo_url is not None:
123154
data['idp']['singleLogoutService'] = {}
124155
data['idp']['singleLogoutService']['url'] = idp_slo_url
156+
data['idp']['singleLogoutService']['binding'] = required_slo_binding
157+
125158
if idp_x509_cert is not None:
126159
data['idp']['x509cert'] = idp_x509_cert
127160

@@ -150,6 +183,34 @@ def merge_settings(settings, new_metadata_settings):
150183
:returns: merged settings
151184
:rtype: dict
152185
"""
153-
result_settings = settings.copy()
154-
result_settings.update(new_metadata_settings)
186+
for d in (settings, new_metadata_settings):
187+
if not isinstance(d, dict):
188+
raise TypeError('Both arguments must be dictionaries.')
189+
190+
# Guarantee to not modify original data (`settings.copy()` would not
191+
# be sufficient, as it's just a shallow copy).
192+
result_settings = deepcopy(settings)
193+
# Merge `new_metadata_settings` into `result_settings`.
194+
dict_deep_merge(result_settings, new_metadata_settings)
155195
return result_settings
196+
197+
198+
def dict_deep_merge(a, b, path=None):
199+
"""Deep-merge dictionary `b` into dictionary `a`.
200+
Kudos to http://stackoverflow.com/a/7205107/145400
201+
"""
202+
if path is None:
203+
path = []
204+
for key in b:
205+
if key in a:
206+
if isinstance(a[key], dict) and isinstance(b[key], dict):
207+
dict_deep_merge(a[key], b[key], path + [str(key)])
208+
elif a[key] == b[key]:
209+
# Key conflict, but equal value.
210+
pass
211+
else:
212+
# Key/value conflict. Prioritize b over a.
213+
a[key] = b[key]
214+
else:
215+
a[key] = b[key]
216+
return a

0 commit comments

Comments
 (0)