Skip to content

Commit 33a7aa5

Browse files
committed
Fix #94 Idp Metadata parser
1 parent 90d9259 commit 33a7aa5

2 files changed

Lines changed: 257 additions & 0 deletions

File tree

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
# -*- coding: utf-8 -*-
2+
3+
""" OneLogin_Saml2_IdPMetadataParser class
4+
5+
Copyright (c) 2014, OneLogin, Inc.
6+
All rights reserved.
7+
8+
Metadata class of OneLogin's Python Toolkit.
9+
10+
"""
11+
12+
import urllib2
13+
from defusedxml.lxml import fromstring
14+
15+
from onelogin.saml2.constants import OneLogin_Saml2_Constants
16+
from onelogin.saml2.utils import OneLogin_Saml2_Utils
17+
18+
19+
class OneLogin_Saml2_IdPMetadataParser(object):
20+
"""
21+
A class that contains methods related to obtain and parse metadata from IdP
22+
"""
23+
24+
@staticmethod
25+
def get_metadata(url):
26+
"""
27+
Get the metadata XML from the provided URL
28+
29+
:param url: Url where the XML of the Identity Provider Metadata is published.
30+
:type url: string
31+
32+
:returns: metadata XML
33+
:rtype: string
34+
"""
35+
valid = False
36+
response = urllib2.urlopen(url)
37+
xml = response.read()
38+
39+
if xml:
40+
try:
41+
dom = fromstring(xml)
42+
idp_descriptor_nodes = OneLogin_Saml2_Utils.query(dom, '//md:IDPSSODescriptor')
43+
if idp_descriptor_nodes:
44+
valid = True
45+
except:
46+
pass
47+
48+
if not valid:
49+
raise Exception('Not valid IdP XML found from URL: %s' % (url))
50+
51+
return xml
52+
53+
@staticmethod
54+
def parse_remote(url):
55+
"""
56+
Get the metadata XML from the provided URL and parse it, returning a dict with extracted data
57+
58+
:param url: Url where the XML of the Identity Provider Metadata is published.
59+
:type url: string
60+
61+
:returns: settings dict with extracted data
62+
:rtype: dict
63+
"""
64+
idp_metadata = OneLogin_Saml2_IdPMetadataParser.get_metadata(url)
65+
return OneLogin_Saml2_IdPMetadataParser.parse(idp_metadata)
66+
67+
@staticmethod
68+
def parse(idp_metadata):
69+
"""
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
72+
73+
:param idp_metadata: XML of the Identity Provider Metadata.
74+
:type idp_metadata: string
75+
76+
:param url: If true and the URL is HTTPs, the cert of the domain is checked.
77+
:type url: bool
78+
79+
:returns: settings dict with extracted data
80+
:rtype: string
81+
"""
82+
data = {}
83+
84+
dom = fromstring(idp_metadata)
85+
entity_descriptor_nodes = OneLogin_Saml2_Utils.query(dom, '//md:EntityDescriptor')
86+
87+
idp_entity_id = want_authn_requests_signed = idp_name_id_format = idp_sso_url = idp_slo_url = idp_x509_cert = None
88+
89+
if len(entity_descriptor_nodes) > 0:
90+
for entity_descriptor_node in entity_descriptor_nodes:
91+
idp_descriptor_nodes = OneLogin_Saml2_Utils.query(entity_descriptor_node, './md:IDPSSODescriptor')
92+
if len(idp_descriptor_nodes) > 0:
93+
idp_descriptor_node = idp_descriptor_nodes[0]
94+
95+
idp_entity_id = entity_descriptor_node.get('entityID', None)
96+
97+
want_authn_requests_signed = entity_descriptor_node.get('WantAuthnRequestsSigned', None)
98+
99+
name_id_format_nodes = OneLogin_Saml2_Utils.query(idp_descriptor_node, './md:NameIDFormat')
100+
if len(name_id_format_nodes) > 0:
101+
idp_name_id_format = name_id_format_nodes[0].text
102+
103+
sso_nodes = OneLogin_Saml2_Utils.query(idp_descriptor_node, "./md:SingleSignOnService[@Binding='%s']" % OneLogin_Saml2_Constants.BINDING_HTTP_REDIRECT)
104+
if len(sso_nodes) > 0:
105+
idp_sso_url = sso_nodes[0].get('Location', None)
106+
107+
slo_nodes = OneLogin_Saml2_Utils.query(idp_descriptor_node, "./md:SingleLogoutService[@Binding='%s']" % OneLogin_Saml2_Constants.BINDING_HTTP_REDIRECT)
108+
if len(slo_nodes) > 0:
109+
idp_slo_url = slo_nodes[0].get('Location', None)
110+
111+
cert_nodes = OneLogin_Saml2_Utils.query(idp_descriptor_node, "./md:KeyDescriptor[@use='signing']/ds:KeyInfo/ds:X509Data/ds:X509Certificate")
112+
if len(cert_nodes) > 0:
113+
idp_x509_cert = cert_nodes[0].text
114+
115+
data['idp'] = {}
116+
117+
if idp_entity_id is not None:
118+
data['idp']['entityId'] = idp_entity_id
119+
if idp_sso_url is not None:
120+
data['idp']['singleLogoutService'] = {}
121+
data['idp']['singleLogoutService']['url'] = idp_sso_url
122+
if idp_slo_url is not None:
123+
data['idp']['singleLogoutService'] = {}
124+
data['idp']['singleLogoutService']['url'] = idp_slo_url
125+
if idp_x509_cert is not None:
126+
data['idp']['x509cert'] = idp_x509_cert
127+
128+
if want_authn_requests_signed is not None:
129+
data['security'] = {}
130+
data['security']['authnRequestsSigned'] = want_authn_requests_signed
131+
132+
if idp_name_id_format:
133+
data['sp'] = {}
134+
data['sp']['NameIDFormat'] = idp_name_id_format
135+
136+
break
137+
return data
138+
139+
@staticmethod
140+
def merge_settings(settings, new_metadata_settings):
141+
"""
142+
Will update the settings with the provided new settings data extracted from the IdP metadata
143+
144+
:param settings: Current settings dict data
145+
:type settings: string
146+
147+
:param new_metadata_settings: Settings to be merged (extracted from IdP metadata after parsing)
148+
:type new_metadata_settings: string
149+
150+
:returns: merged settings
151+
:rtype: dict
152+
"""
153+
result_settings = settings.copy()
154+
result_settings.update(new_metadata_settings)
155+
return result_settings
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
# -*- coding: utf-8 -*-
2+
3+
# Copyright (c) 2014, OneLogin, Inc.
4+
# All rights reserved.
5+
6+
7+
import json
8+
from os.path import dirname, join, exists
9+
from lxml.etree import XMLSyntaxError
10+
import unittest
11+
from teamcity import is_running_under_teamcity
12+
from teamcity.unittestpy import TeamcityTestRunner
13+
14+
from onelogin.saml2.idp_metadata_parser import OneLogin_Saml2_IdPMetadataParser
15+
16+
17+
class OneLogin_Saml2_IdPMetadataParser_Test(unittest.TestCase):
18+
data_path = join(dirname(dirname(dirname(dirname(__file__)))), 'data')
19+
settings_path = join(dirname(dirname(dirname(dirname(__file__)))), 'settings')
20+
21+
def loadSettingsJSON(self, filename='settings1.json'):
22+
filename = join(self.settings_path, filename)
23+
if exists(filename):
24+
stream = open(filename, 'r')
25+
settings = json.load(stream)
26+
stream.close()
27+
return settings
28+
else:
29+
raise Exception('Settings json file does not exist')
30+
31+
def file_contents(self, filename):
32+
f = open(filename, 'r')
33+
content = f.read()
34+
f.close()
35+
return content
36+
37+
def testGetMetadata(self):
38+
"""
39+
Tests the get_metadata method of the OneLogin_Saml2_IdPMetadataParser
40+
"""
41+
with self.assertRaises(Exception):
42+
data = OneLogin_Saml2_IdPMetadataParser.get_metadata('http://google.es')
43+
44+
data = OneLogin_Saml2_IdPMetadataParser.get_metadata('https://www.testshib.org/metadata/testshib-providers.xml')
45+
self.assertTrue(data is not None and data is not {})
46+
47+
def testParseRemote(self):
48+
"""
49+
Tests the parse_remote method of the OneLogin_Saml2_IdPMetadataParser
50+
"""
51+
with self.assertRaises(Exception):
52+
data = OneLogin_Saml2_IdPMetadataParser.parse_remote('http://google.es')
53+
54+
data = OneLogin_Saml2_IdPMetadataParser.parse_remote('https://www.testshib.org/metadata/testshib-providers.xml')
55+
self.assertTrue(data is not None and data is not {})
56+
expected_data = {'sp': {'NameIDFormat': 'urn:mace:shibboleth:1.0:nameIdentifier'}, 'idp': {'singleLogoutService': {'url': 'https://idp.testshib.org/idp/profile/SAML2/Redirect/SSO'}, 'entityId': 'https://idp.testshib.org/idp/shibboleth'}}
57+
self.assertEqual(expected_data, data)
58+
59+
def testParse(self):
60+
"""
61+
Tests the parse method of the OneLogin_Saml2_IdPMetadataParser
62+
"""
63+
with self.assertRaises(XMLSyntaxError):
64+
data = OneLogin_Saml2_IdPMetadataParser.parse('')
65+
66+
xml_sp_metadata = self.file_contents(join(self.data_path, 'metadata', 'metadata_settings1.xml'))
67+
data = OneLogin_Saml2_IdPMetadataParser.parse(xml_sp_metadata)
68+
self.assertEqual({}, data)
69+
70+
xml_idp_metadata = self.file_contents(join(self.data_path, 'metadata', 'idp_metadata.xml'))
71+
data = OneLogin_Saml2_IdPMetadataParser.parse(xml_idp_metadata)
72+
expected_data = {'sp': {'NameIDFormat': 'urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress'}, 'idp': {'singleLogoutService': {'url': 'https://app.onelogin.com/trust/saml2/http-post/sso/383123'}, 'entityId': 'https://app.onelogin.com/saml/metadata/383123', 'x509cert': 'MIIEHjCCAwagAwIBAgIBATANBgkqhkiG9w0BAQUFADBnMQswCQYDVQQGEwJVUzET\nMBEGA1UECAwKQ2FsaWZvcm5pYTEVMBMGA1UEBwwMU2FudGEgTW9uaWNhMREwDwYD\nVQQKDAhPbmVMb2dpbjEZMBcGA1UEAwwQYXBwLm9uZWxvZ2luLmNvbTAeFw0xMzA2\nMDUxNzE2MjBaFw0xODA2MDUxNzE2MjBaMGcxCzAJBgNVBAYTAlVTMRMwEQYDVQQI\nDApDYWxpZm9ybmlhMRUwEwYDVQQHDAxTYW50YSBNb25pY2ExETAPBgNVBAoMCE9u\nZUxvZ2luMRkwFwYDVQQDDBBhcHAub25lbG9naW4uY29tMIIBIjANBgkqhkiG9w0B\nAQEFAAOCAQ8AMIIBCgKCAQEAse8rnep4qL2GmhH10pMQyJ2Jae+AQHyfgVjaQZ7Z\n0QQog5jX91vcJRSMi0XWJnUtOr6lF0dq1+yckjZ92wyLrH+7fvngNO1aV4Mjk9sT\ngf+iqMrae6y6fRxDt9PXrEFVjvd3vv7QTJf2FuIPy4vVP06Dt8EMkQIr8rmLmU0m\nTr1k2DkrdtdlCuNFTXuAu3QqfvNCRrRwfNObn9MP6JeOUdcGLJsBjGF8exfcN1SF\nzRF0JFr3dmOlx761zK5liD0T1sYWnDquatj/JD9fZMbKecBKni1NglH/LVd+b6aJ\nUAr5LulERULUjLqYJRKW31u91/4Qazdo9tbvwqyFxaoUrwIDAQABo4HUMIHRMAwG\nA1UdEwEB/wQCMAAwHQYDVR0OBBYEFPWcXvQSlTXnzZD2xziuoUvrrDedMIGRBgNV\nHSMEgYkwgYaAFPWcXvQSlTXnzZD2xziuoUvrrDedoWukaTBnMQswCQYDVQQGEwJV\nUzETMBEGA1UECAwKQ2FsaWZvcm5pYTEVMBMGA1UEBwwMU2FudGEgTW9uaWNhMREw\nDwYDVQQKDAhPbmVMb2dpbjEZMBcGA1UEAwwQYXBwLm9uZWxvZ2luLmNvbYIBATAO\nBgNVHQ8BAf8EBAMCBPAwDQYJKoZIhvcNAQEFBQADggEBAB/8xe3rzqXQVxzHyAHu\nAuPa73ClDoL1cko0Fp8CGcqEIyj6Te9gx5z6wyfv+Lo8RFvBLlnB1lXqbC+fTGcV\ngG/4oKLJ5UwRFxInqpZPnOAudVNnd0PYOODn9FWs6u+OTIQIaIcPUv3MhB9lwHIJ\nsTk/bs9xcru5TPyLIxLLd6ib/pRceKH2mTkzUd0DYk9CQNXXeoGx/du5B9nh3ClP\nTbVakRzl3oswgI5MQIphYxkW70SopEh4kOFSRE1ND31NNIq1YrXlgtkguQBFsZWu\nQOPR6cEwFZzP0tHTYbI839WgxX6hfhIUTUz6mLqq4+3P4BG3+1OXeVDg63y8Uh78\n1sE='}}
73+
self.assertEqual(expected_data, data)
74+
75+
def testMergeSettings(self):
76+
"""
77+
Tests the merge_settings method of the OneLogin_Saml2_IdPMetadataParser
78+
"""
79+
with self.assertRaises(AttributeError):
80+
settings_result = OneLogin_Saml2_IdPMetadataParser.merge_settings(None, {})
81+
82+
with self.assertRaises(TypeError):
83+
settings_result = OneLogin_Saml2_IdPMetadataParser.merge_settings({}, None)
84+
85+
xml_idp_metadata = self.file_contents(join(self.data_path, 'metadata', 'idp_metadata.xml'))
86+
data = OneLogin_Saml2_IdPMetadataParser.parse(xml_idp_metadata)
87+
settings = self.loadSettingsJSON()
88+
settings_result = OneLogin_Saml2_IdPMetadataParser.merge_settings(settings, data)
89+
expected_data = {u'sp': {'NameIDFormat': 'urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress'}, u'idp': {'singleLogoutService': {'url': 'https://app.onelogin.com/trust/saml2/http-post/sso/383123'}, 'entityId': 'https://app.onelogin.com/saml/metadata/383123', 'x509cert': 'MIIEHjCCAwagAwIBAgIBATANBgkqhkiG9w0BAQUFADBnMQswCQYDVQQGEwJVUzET\nMBEGA1UECAwKQ2FsaWZvcm5pYTEVMBMGA1UEBwwMU2FudGEgTW9uaWNhMREwDwYD\nVQQKDAhPbmVMb2dpbjEZMBcGA1UEAwwQYXBwLm9uZWxvZ2luLmNvbTAeFw0xMzA2\nMDUxNzE2MjBaFw0xODA2MDUxNzE2MjBaMGcxCzAJBgNVBAYTAlVTMRMwEQYDVQQI\nDApDYWxpZm9ybmlhMRUwEwYDVQQHDAxTYW50YSBNb25pY2ExETAPBgNVBAoMCE9u\nZUxvZ2luMRkwFwYDVQQDDBBhcHAub25lbG9naW4uY29tMIIBIjANBgkqhkiG9w0B\nAQEFAAOCAQ8AMIIBCgKCAQEAse8rnep4qL2GmhH10pMQyJ2Jae+AQHyfgVjaQZ7Z\n0QQog5jX91vcJRSMi0XWJnUtOr6lF0dq1+yckjZ92wyLrH+7fvngNO1aV4Mjk9sT\ngf+iqMrae6y6fRxDt9PXrEFVjvd3vv7QTJf2FuIPy4vVP06Dt8EMkQIr8rmLmU0m\nTr1k2DkrdtdlCuNFTXuAu3QqfvNCRrRwfNObn9MP6JeOUdcGLJsBjGF8exfcN1SF\nzRF0JFr3dmOlx761zK5liD0T1sYWnDquatj/JD9fZMbKecBKni1NglH/LVd+b6aJ\nUAr5LulERULUjLqYJRKW31u91/4Qazdo9tbvwqyFxaoUrwIDAQABo4HUMIHRMAwG\nA1UdEwEB/wQCMAAwHQYDVR0OBBYEFPWcXvQSlTXnzZD2xziuoUvrrDedMIGRBgNV\nHSMEgYkwgYaAFPWcXvQSlTXnzZD2xziuoUvrrDedoWukaTBnMQswCQYDVQQGEwJV\nUzETMBEGA1UECAwKQ2FsaWZvcm5pYTEVMBMGA1UEBwwMU2FudGEgTW9uaWNhMREw\nDwYDVQQKDAhPbmVMb2dpbjEZMBcGA1UEAwwQYXBwLm9uZWxvZ2luLmNvbYIBATAO\nBgNVHQ8BAf8EBAMCBPAwDQYJKoZIhvcNAQEFBQADggEBAB/8xe3rzqXQVxzHyAHu\nAuPa73ClDoL1cko0Fp8CGcqEIyj6Te9gx5z6wyfv+Lo8RFvBLlnB1lXqbC+fTGcV\ngG/4oKLJ5UwRFxInqpZPnOAudVNnd0PYOODn9FWs6u+OTIQIaIcPUv3MhB9lwHIJ\nsTk/bs9xcru5TPyLIxLLd6ib/pRceKH2mTkzUd0DYk9CQNXXeoGx/du5B9nh3ClP\nTbVakRzl3oswgI5MQIphYxkW70SopEh4kOFSRE1ND31NNIq1YrXlgtkguQBFsZWu\nQOPR6cEwFZzP0tHTYbI839WgxX6hfhIUTUz6mLqq4+3P4BG3+1OXeVDg63y8Uh78\n1sE='}, u'strict': False, u'contactPerson': {u'technical': {u'givenName': u'technical_name', u'emailAddress': u'technical@example.com'}, u'support': {u'givenName': u'support_name', u'emailAddress': u'support@example.com'}}, u'debug': False, u'organization': {u'en-US': {u'url': u'http://sp.example.com', u'displayname': u'SP test', u'name': u'sp_test'}}, u'security': {u'signMetadata': False, u'wantAssertionsSigned': False, u'authnRequestsSigned': False}, u'custom_base_path': u'../../../tests/data/customPath/'}
90+
self.assertEqual(expected_data, settings_result)
91+
92+
expected_data2 = {'sp': {u'singleLogoutService': {u'url': u'http://stuff.com/endpoints/endpoints/sls.php'}, u'assertionConsumerService': {u'url': u'http://stuff.com/endpoints/endpoints/acs.php'}, u'entityId': u'http://stuff.com/endpoints/metadata.php', u'NameIDFormat': u'urn:oasis:names:tc:SAML:2.0:nameid-format:unspecified'}, 'idp': {u'singleLogoutService': {u'url': u'http://idp.example.com/SingleLogoutService.php'}, u'entityId': u'http://idp.example.com/', u'x509cert': u'MIICgTCCAeoCCQCbOlrWDdX7FTANBgkqhkiG9w0BAQUFADCBhDELMAkGA1UEBhMCTk8xGDAWBgNVBAgTD0FuZHJlYXMgU29sYmVyZzEMMAoGA1UEBxMDRm9vMRAwDgYDVQQKEwdVTklORVRUMRgwFgYDVQQDEw9mZWlkZS5lcmxhbmcubm8xITAfBgkqhkiG9w0BCQEWEmFuZHJlYXNAdW5pbmV0dC5ubzAeFw0wNzA2MTUxMjAxMzVaFw0wNzA4MTQxMjAxMzVaMIGEMQswCQYDVQQGEwJOTzEYMBYGA1UECBMPQW5kcmVhcyBTb2xiZXJnMQwwCgYDVQQHEwNGb28xEDAOBgNVBAoTB1VOSU5FVFQxGDAWBgNVBAMTD2ZlaWRlLmVybGFuZy5ubzEhMB8GCSqGSIb3DQEJARYSYW5kcmVhc0B1bmluZXR0Lm5vMIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDivbhR7P516x/S3BqKxupQe0LONoliupiBOesCO3SHbDrl3+q9IbfnfmE04rNuMcPsIxB161TdDpIesLCn7c8aPHISKOtPlAeTZSnb8QAu7aRjZq3+PbrP5uW3TcfCGPtKTytHOge/OlJbo078dVhXQ14d1EDwXJW1rRXuUt4C8QIDAQABMA0GCSqGSIb3DQEBBQUAA4GBACDVfp86HObqY+e8BUoWQ9+VMQx1ASDohBjwOsg2WykUqRXF+dLfcUH9dWR63CtZIKFDbStNomPnQz7nbK+onygwBspVEbnHuUihZq3ZUdmumQqCw4Uvs/1Uvq3orOo/WJVhTyvLgFVK2QarQ4/67OZfHd7R+POBXhophSMv1ZOo', u'singleSignOnService': {u'url': u'http://idp.example.com/SSOService.php'}}, u'strict': False, u'contactPerson': {u'technical': {u'givenName': u'technical_name', u'emailAddress': u'technical@example.com'}, u'support': {u'givenName': u'support_name', u'emailAddress': u'support@example.com'}}, u'debug': False, u'organization': {u'en-US': {u'url': u'http://sp.example.com', u'displayname': u'SP test', u'name': u'sp_test'}}, u'security': {u'signMetadata': False, u'wantAssertionsSigned': False, u'authnRequestsSigned': False}, u'custom_base_path': u'../../../tests/data/customPath/'}
93+
settings_result2 = OneLogin_Saml2_IdPMetadataParser.merge_settings(data, settings)
94+
self.assertEqual(expected_data2, settings_result2)
95+
96+
97+
if __name__ == '__main__':
98+
if is_running_under_teamcity():
99+
runner = TeamcityTestRunner()
100+
else:
101+
runner = unittest.TextTestRunner()
102+
unittest.main(testRunner=runner)

0 commit comments

Comments
 (0)