Skip to content

Commit 8ce02da

Browse files
committed
Support multiple assertion consumer services (#102)
1 parent db1e9a6 commit 8ce02da

7 files changed

Lines changed: 137 additions & 15 deletions

File tree

src/onelogin/saml2/auth.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -318,7 +318,7 @@ def get_last_assertion_id(self):
318318
"""
319319
return self.__last_assertion_id
320320

321-
def login(self, return_to=None, force_authn=False, is_passive=False, set_nameid_policy=True):
321+
def login(self, return_to=None, force_authn=False, is_passive=False, set_nameid_policy=True, acs_index=None):
322322
"""
323323
Initiates the SSO process.
324324
@@ -334,10 +334,13 @@ def login(self, return_to=None, force_authn=False, is_passive=False, set_nameid_
334334
:param set_nameid_policy: Optional argument. When true the AuthNRequest will set a nameIdPolicy element.
335335
:type set_nameid_policy: bool
336336
337+
:param acs_index: Optional argument. The index of the assertionConsumerService to use, if multiple were specified.
338+
:type acs_index: int
339+
337340
:returns: Redirection URL
338341
:rtype: string
339342
"""
340-
authn_request = OneLogin_Saml2_Authn_Request(self.__settings, force_authn, is_passive, set_nameid_policy)
343+
authn_request = OneLogin_Saml2_Authn_Request(self.__settings, force_authn, is_passive, set_nameid_policy, acs_index)
341344
self.__last_request = authn_request.get_xml()
342345
self.__last_request_id = authn_request.get_id()
343346

src/onelogin/saml2/authn_request.py

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
1010
"""
1111

12+
from onelogin.saml2 import compat
1213
from onelogin.saml2.constants import OneLogin_Saml2_Constants
1314
from onelogin.saml2.utils import OneLogin_Saml2_Utils
1415
from onelogin.saml2.xml_templates import OneLogin_Saml2_Templates
@@ -22,7 +23,7 @@ class OneLogin_Saml2_Authn_Request(object):
2223
2324
"""
2425

25-
def __init__(self, settings, force_authn=False, is_passive=False, set_nameid_policy=True):
26+
def __init__(self, settings, force_authn=False, is_passive=False, set_nameid_policy=True, acs_index=None):
2627
"""
2728
Constructs the AuthnRequest object.
2829
@@ -37,6 +38,9 @@ def __init__(self, settings, force_authn=False, is_passive=False, set_nameid_pol
3738
3839
:param set_nameid_policy: Optional argument. When true the AuthNRequest will set a nameIdPolicy element.
3940
:type set_nameid_policy: bool
41+
42+
:param acs_index: Optional argument. The index of the assertionConsumerService to use, if multiple were specified.
43+
:type acs_index: int
4044
"""
4145
self.__settings = settings
4246

@@ -102,6 +106,19 @@ def __init__(self, settings, force_authn=False, is_passive=False, set_nameid_pol
102106
if 'attributeConsumingService' in sp_data and sp_data['attributeConsumingService']:
103107
attr_consuming_service_str = "\n AttributeConsumingServiceIndex=\"1\""
104108

109+
assertion_url = ''
110+
if isinstance(sp_data['assertionConsumerService'], dict):
111+
assertion_url = sp_data['assertionConsumerService']['url']
112+
else:
113+
for idx, acs in enumerate(sp_data['assertionConsumerService']):
114+
if idx == 0:
115+
# By default, use the first assertion consumer service if an index is not specified.
116+
assertion_url = acs['url']
117+
index = compat.to_string(acs.get('index', idx))
118+
if index == compat.to_string(acs_index):
119+
assertion_url = acs['url']
120+
break
121+
105122
request = OneLogin_Saml2_Templates.AUTHN_REQUEST % \
106123
{
107124
'id': uid,
@@ -110,7 +127,7 @@ def __init__(self, settings, force_authn=False, is_passive=False, set_nameid_pol
110127
'is_passive_str': is_passive_str,
111128
'issue_instant': issue_instant,
112129
'destination': destination,
113-
'assertion_url': sp_data['assertionConsumerService']['url'],
130+
'assertion_url': assertion_url,
114131
'entity_id': sp_data['entityId'],
115132
'nameid_policy_str': nameid_policy_str,
116133
'requested_authn_context_str': requested_authn_context_str,

src/onelogin/saml2/metadata.py

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,21 @@ def builder(sp, authnsign=False, wsign=False, valid_until=None, cache_duration=N
173173
'requested_attribute_str': '\n'.join(requested_attribute_data)
174174
}
175175

176+
str_assertion_consumers = ''
177+
if isinstance(sp['assertionConsumerService'], dict):
178+
str_assertion_consumers += OneLogin_Saml2_Templates.MD_ASSERTION_CONSUMER_SERVICE % {
179+
'binding': sp['assertionConsumerService']['binding'],
180+
'location': sp['assertionConsumerService']['url'],
181+
'index': sp['assertionConsumerService'].get('index', '1')
182+
}
183+
else:
184+
for idx, acs in enumerate(sp['assertionConsumerService']):
185+
str_assertion_consumers += OneLogin_Saml2_Templates.MD_ASSERTION_CONSUMER_SERVICE % {
186+
'binding': acs['binding'],
187+
'location': acs['url'],
188+
'index': acs.get('index', compat.to_string(idx))
189+
}
190+
176191
metadata = OneLogin_Saml2_Templates.MD_ENTITY_DESCRIPTOR % \
177192
{
178193
'valid': ('validUntil="%s"' % valid_until_str) if valid_until_str else '',
@@ -181,8 +196,7 @@ def builder(sp, authnsign=False, wsign=False, valid_until=None, cache_duration=N
181196
'authnsign': str_authnsign,
182197
'wsign': str_wsign,
183198
'name_id_format': sp['NameIDFormat'],
184-
'binding': sp['assertionConsumerService']['binding'],
185-
'location': sp['assertionConsumerService']['url'],
199+
'assertion_consumers': str_assertion_consumers,
186200
'sls': sls,
187201
'organization': str_organization,
188202
'contacts': str_contacts,

src/onelogin/saml2/settings.py

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -258,8 +258,12 @@ def __add_default_values(self):
258258
"""
259259
Add default values if the settings info is not complete
260260
"""
261-
self.__sp.setdefault('assertionConsumerService', {})
262-
self.__sp['assertionConsumerService'].setdefault('binding', OneLogin_Saml2_Constants.BINDING_HTTP_POST)
261+
acs = self.__sp.setdefault('assertionConsumerService', {})
262+
if isinstance(acs, dict):
263+
acs.setdefault('binding', OneLogin_Saml2_Constants.BINDING_HTTP_POST)
264+
else:
265+
for entry in acs:
266+
entry.setdefault('binding', OneLogin_Saml2_Constants.BINDING_HTTP_POST)
263267

264268
self.__sp.setdefault('attributeConsumingService', {})
265269

@@ -415,10 +419,22 @@ def check_sp_settings(self, settings):
415419
if not sp.get('entityId'):
416420
errors.append('sp_entityId_not_found')
417421

418-
if not sp.get('assertionConsumerService', {}).get('url'):
419-
errors.append('sp_acs_not_found')
420-
elif not validate_url(sp['assertionConsumerService']['url']):
421-
errors.append('sp_acs_url_invalid')
422+
acs_list = sp.get('assertionConsumerService', {})
423+
acs_indexes = set()
424+
if isinstance(acs_list, dict):
425+
acs_list = [acs_list]
426+
if not isinstance(acs_list, list):
427+
errors.append('sp_acs_invalid_type')
428+
else:
429+
for idx, acs in enumerate(acs_list):
430+
index = compat.to_string(acs.get('index', idx))
431+
if index in acs_indexes:
432+
errors.append('sp_acs_duplicate_index')
433+
acs_indexes.add(index)
434+
if not acs.get('url'):
435+
errors.append('sp_acs_not_found')
436+
elif not validate_url(acs['url']):
437+
errors.append('sp_acs_url_invalid')
422438

423439
if sp.get('attributeConsumingService'):
424440
attributeConsumingService = sp['attributeConsumingService']

src/onelogin/saml2/xml_templates.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,11 @@ class OneLogin_Saml2_Templates(object):
8080
%(attr_cs_desc)s%(requested_attribute_str)s
8181
</md:AttributeConsumingService>\n"""
8282

83+
MD_ASSERTION_CONSUMER_SERVICE = """\
84+
<md:AssertionConsumerService Binding="%(binding)s"
85+
Location="%(location)s"
86+
index="%(index)s" />\n"""
87+
8388
MD_ENTITY_DESCRIPTOR = """\
8489
<?xml version="1.0"?>
8590
<md:EntityDescriptor xmlns:md="urn:oasis:names:tc:SAML:2.0:metadata"
@@ -88,9 +93,7 @@ class OneLogin_Saml2_Templates(object):
8893
entityID="%(entity_id)s">
8994
<md:SPSSODescriptor AuthnRequestsSigned="%(authnsign)s" WantAssertionsSigned="%(wsign)s" protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol">
9095
%(sls)s <md:NameIDFormat>%(name_id_format)s</md:NameIDFormat>
91-
<md:AssertionConsumerService Binding="%(binding)s"
92-
Location="%(location)s"
93-
index="1" />
96+
%(assertion_consumers)s
9497
%(attribute_consuming_service)s </md:SPSSODescriptor>
9598
%(organization)s
9699
%(contacts)s

tests/settings/settings9.json

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
{
2+
"strict": false,
3+
"debug": false,
4+
"custom_base_path": "../../../tests/data/customPath/",
5+
"sp": {
6+
"entityId": "http://stuff.com/endpoints/metadata.php",
7+
"assertionConsumerService": [
8+
{
9+
"url": "http://stuff.com/endpoints/endpoints/acs.php",
10+
"index": "123"
11+
},
12+
{
13+
"url": "http://stuff.com/endpoints/endpoints/acs2.php",
14+
"index": "456"
15+
},
16+
{
17+
"url": "http://stuff.com/endpoints/endpoints/acs3.php",
18+
"index": "789"
19+
}
20+
],
21+
"singleLogoutService": {
22+
"url": "http://stuff.com/endpoints/endpoints/sls.php"
23+
},
24+
"NameIDFormat": "urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified"
25+
},
26+
"idp": {
27+
"entityId": "http://idp.example.com/",
28+
"singleSignOnService": {
29+
"url": "http://idp.example.com/SSOService.php"
30+
},
31+
"singleLogoutService": {
32+
"url": "http://idp.example.com/SingleLogoutService.php"
33+
},
34+
"x509cert": "MIICgTCCAeoCCQCbOlrWDdX7FTANBgkqhkiG9w0BAQUFADCBhDELMAkGA1UEBhMCTk8xGDAWBgNVBAgTD0FuZHJlYXMgU29sYmVyZzEMMAoGA1UEBxMDRm9vMRAwDgYDVQQKEwdVTklORVRUMRgwFgYDVQQDEw9mZWlkZS5lcmxhbmcubm8xITAfBgkqhkiG9w0BCQEWEmFuZHJlYXNAdW5pbmV0dC5ubzAeFw0wNzA2MTUxMjAxMzVaFw0wNzA4MTQxMjAxMzVaMIGEMQswCQYDVQQGEwJOTzEYMBYGA1UECBMPQW5kcmVhcyBTb2xiZXJnMQwwCgYDVQQHEwNGb28xEDAOBgNVBAoTB1VOSU5FVFQxGDAWBgNVBAMTD2ZlaWRlLmVybGFuZy5ubzEhMB8GCSqGSIb3DQEJARYSYW5kcmVhc0B1bmluZXR0Lm5vMIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDivbhR7P516x/S3BqKxupQe0LONoliupiBOesCO3SHbDrl3+q9IbfnfmE04rNuMcPsIxB161TdDpIesLCn7c8aPHISKOtPlAeTZSnb8QAu7aRjZq3+PbrP5uW3TcfCGPtKTytHOge/OlJbo078dVhXQ14d1EDwXJW1rRXuUt4C8QIDAQABMA0GCSqGSIb3DQEBBQUAA4GBACDVfp86HObqY+e8BUoWQ9+VMQx1ASDohBjwOsg2WykUqRXF+dLfcUH9dWR63CtZIKFDbStNomPnQz7nbK+onygwBspVEbnHuUihZq3ZUdmumQqCw4Uvs/1Uvq3orOo/WJVhTyvLgFVK2QarQ4/67OZfHd7R+POBXhophSMv1ZOo"
35+
},
36+
"security": {
37+
"authnRequestsSigned": false,
38+
"wantAssertionsSigned": false,
39+
"signMetadata": false
40+
},
41+
"contactPerson": {
42+
"technical": {
43+
"givenName": "technical_name",
44+
"emailAddress": "technical@example.com"
45+
},
46+
"support": {
47+
"givenName": "support_name",
48+
"emailAddress": "support@example.com"
49+
}
50+
},
51+
"organization": {
52+
"en-US": {
53+
"name": "sp_test",
54+
"displayname": "SP test",
55+
"url": "http://sp.example.com"
56+
}
57+
}
58+
}

tests/src/OneLogin/saml2_tests/authn_request_test.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -339,3 +339,14 @@ def testAttributeConsumingService(self):
339339
inflated = compat.to_string(OneLogin_Saml2_Utils.decode_base64_and_inflate(authn_request_encoded))
340340

341341
self.assertRegex(inflated, 'AttributeConsumingServiceIndex="1"')
342+
343+
def testMultipleAssertionConsumerServices(self):
344+
settings_data = self.loadSettingsJSON('settings9.json')
345+
settings = OneLogin_Saml2_Settings(settings_data)
346+
self.assertEqual(len(settings.get_errors()), 0)
347+
348+
authn_request = OneLogin_Saml2_Authn_Request(settings, acs_index=456)
349+
authn_request_encoded = authn_request.get_request()
350+
inflated = compat.to_string(OneLogin_Saml2_Utils.decode_base64_and_inflate(authn_request_encoded))
351+
352+
self.assertRegex(inflated, 'AssertionConsumerServiceURL="http://stuff.com/endpoints/endpoints/acs2.php">')

0 commit comments

Comments
 (0)