Skip to content

Commit 6476aeb

Browse files
committed
Merge pull request #1 from jimmyislive/feature/requested_attrib_support
Feature/requested attrib support, feature branch -> master branch
2 parents 57eb202 + 39c366d commit 6476aeb

7 files changed

Lines changed: 182 additions & 6 deletions

File tree

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
{
2+
"strict": true,
3+
"debug": true,
4+
"sp": {
5+
"entityId": "https://<sp_domain>/metadata/",
6+
"assertionConsumerService": {
7+
"url": "https://<sp_domain>/?acs",
8+
"binding": "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST"
9+
},
10+
"attributeConsumingService": [
11+
{
12+
"isDefault": false,
13+
"serviceName": "Django Demo",
14+
"serviceDescription": "Django Name",
15+
"requestedAttributes": [ {
16+
"name": "",
17+
"nameFormat": "",
18+
"friendlyName": "",
19+
"isRequired": false,
20+
"attributeValue": [
21+
]
22+
}
23+
]
24+
}
25+
],
26+
"singleLogoutService": {
27+
"url": "https://<sp_domain>/?sls",
28+
"binding": "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect"
29+
},
30+
"NameIDFormat": "urn:oasis:names:tc:SAML:2.0:nameid-format:unspecified",
31+
"x509cert": "",
32+
"privateKey": ""
33+
},
34+
"idp": {
35+
"entityId": "https://app.onelogin.com/saml/metadata/<onelogin_connector_id>",
36+
"singleSignOnService": {
37+
"url": "https://app.onelogin.com/trust/saml2/http-post/sso/<onelogin_connector_id>",
38+
"binding": "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect"
39+
},
40+
"singleLogoutService": {
41+
"url": "https://app.onelogin.com/trust/saml2/http-redirect/slo/<onelogin_connector_id>",
42+
"binding": "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect"
43+
},
44+
"x509cert": "<onelogin_connector_cert>"
45+
}
46+
}

setup.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99

1010
setup(
1111
name='python-saml',
12-
version='2.1.5',
12+
version='2.2.5',
1313
description='Onelogin Python Toolkit. Add SAML support to your Python software using this library',
1414
classifiers=[
1515
'Development Status :: 4 - Beta',

src/onelogin/saml2/authn_request.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,12 @@ def __init__(self, settings, force_authn=False, is_passive=False):
8888
requested_authn_context_str += '<saml:AuthnContextClassRef>%s</saml:AuthnContextClassRef>' % authn_context
8989
requested_authn_context_str += ' </samlp:RequestedAuthnContext>'
9090

91+
attr_consuming_service_str = ''
92+
if 'attributeConsumingService' in sp_data and sp_data['attributeConsumingService']:
93+
# TODO: Do we have to account for the case when we have multiple attributeconsumers?
94+
# like will the index be > 1?
95+
attr_consuming_service_str = 'AttributeConsumingServiceIndex="1"'
96+
9197
request = """<samlp:AuthnRequest
9298
xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol"
9399
xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion"
@@ -99,7 +105,8 @@ def __init__(self, settings, force_authn=False, is_passive=False):
99105
IssueInstant="%(issue_instant)s"
100106
Destination="%(destination)s"
101107
ProtocolBinding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST"
102-
AssertionConsumerServiceURL="%(assertion_url)s">
108+
AssertionConsumerServiceURL="%(assertion_url)s"
109+
%(attr_consuming_service_str)s>
103110
<saml:Issuer>%(entity_id)s</saml:Issuer>
104111
<samlp:NameIDPolicy
105112
Format="%(name_id_policy)s"
@@ -117,6 +124,7 @@ def __init__(self, settings, force_authn=False, is_passive=False):
117124
'entity_id': sp_data['entityId'],
118125
'name_id_policy': name_id_policy_format,
119126
'requested_authn_context_str': requested_authn_context_str,
127+
'attr_consuming_service_str': attr_consuming_service_str
120128
}
121129

122130
self.__authn_request = request

src/onelogin/saml2/constants.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,9 @@ class OneLogin_Saml2_Constants(object):
2121
# Value added to the current time in time condition validations
2222
ALLOWED_CLOCK_DRIFT = 300
2323

24+
XML = 'http://www.w3.org/XML/1998/namespace'
25+
XSI = 'http://www.w3.org/2001/XMLSchema-instance'
26+
2427
# NameID Formats
2528
NAMEID_EMAIL_ADDRESS = 'urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress'
2629
NAMEID_X509_SUBJECT_NAME = 'urn:oasis:names:tc:SAML:1.1:nameid-format:X509SubjectName'

src/onelogin/saml2/metadata.py

Lines changed: 73 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
from time import gmtime, strftime
1313
from datetime import datetime
1414
from defusedxml.minidom import parseString
15+
from lxml import etree
1516

1617
from onelogin.saml2.constants import OneLogin_Saml2_Constants
1718
from onelogin.saml2.utils import OneLogin_Saml2_Utils
@@ -27,6 +28,63 @@ class OneLogin_Saml2_Metadata(object):
2728
TIME_VALID = 172800 # 2 days
2829
TIME_CACHED = 604800 # 1 week
2930

31+
@staticmethod
32+
def add_attribute_consuming_service(root, attr_consuming_service):
33+
"""Helper function to add the AttributeConsumingService nodes"""
34+
attrib_index = 1
35+
spso_node = root.find('{%s}SPSSODescriptor' % OneLogin_Saml2_Constants.NS_MD)
36+
37+
# iterate through all the consuming services listed
38+
for acs in attr_consuming_service:
39+
acs_node = etree.SubElement(spso_node, "{%s}AttributeConsumingService" % OneLogin_Saml2_Constants.NS_MD)
40+
acs_node.set('index', str(attrib_index))
41+
try:
42+
acs_node.set('isDefault', str(acs['isDefault']).lower())
43+
except KeyError:
44+
pass
45+
46+
svc_name = etree.SubElement(acs_node, "{%s}ServiceName" % OneLogin_Saml2_Constants.NS_MD)
47+
svc_name.set('{%s}lang' % OneLogin_Saml2_Constants.XML, 'en')
48+
svc_name.text = acs['serviceName']
49+
try:
50+
svc_description = etree.SubElement(acs_node, "{%s}ServiceDescription" % OneLogin_Saml2_Constants.NS_MD)
51+
svc_description.set('{%s}lang' % OneLogin_Saml2_Constants.XML, 'en')
52+
svc_description.text = acs['serviceDescription']
53+
except KeyError:
54+
# serviceDescription is optional
55+
pass
56+
57+
# iterate through all the requested attributes of each service
58+
for req_attribs in acs['requestedAttributes']:
59+
60+
requested_attribute = etree.SubElement(acs_node, "{%s}RequestedAttribute" % OneLogin_Saml2_Constants.NS_MD)
61+
# construct the permissible attrib values, if present
62+
try:
63+
for attrib_val in req_attribs['attributeValue']:
64+
val = etree.SubElement(requested_attribute, "{%s}AttributeValue" % OneLogin_Saml2_Constants.NS_SAML)
65+
val.text = attrib_val
66+
except KeyError:
67+
# it's fine, attributeValue is an optional element
68+
pass
69+
70+
requested_attribute.set('Name', req_attribs['name'])
71+
try:
72+
requested_attribute.set('NameFormat', req_attribs['nameFormat'])
73+
except KeyError:
74+
pass
75+
76+
try:
77+
requested_attribute.set('FriendlyName', req_attribs['friendlyName'])
78+
except KeyError:
79+
pass
80+
81+
try:
82+
requested_attribute.set('isRequired', str(req_attribs['isRequired']).lower())
83+
except KeyError:
84+
pass
85+
86+
attrib_index += 1
87+
3088
@staticmethod
3189
def builder(sp, authnsign=False, wsign=False, valid_until=None, cache_duration=None, contacts=None, organization=None):
3290
"""
@@ -50,7 +108,7 @@ def builder(sp, authnsign=False, wsign=False, valid_until=None, cache_duration=N
50108
:param contacts: Contacts info
51109
:type contacts: dict
52110
53-
:param organization: Organization ingo
111+
:param organization: Organization info
54112
:type organization: dict
55113
"""
56114
if valid_until is None:
@@ -76,6 +134,11 @@ def builder(sp, authnsign=False, wsign=False, valid_until=None, cache_duration=N
76134
if organization is None:
77135
organization = {}
78136

137+
try:
138+
attr_consuming_service = sp['attributeConsumingService']
139+
except KeyError:
140+
attr_consuming_service = []
141+
79142
sls = ''
80143
if 'singleLogoutService' in sp and 'url' in sp['singleLogoutService']:
81144
sls = """ <md:SingleLogoutService Binding="%(binding)s"
@@ -119,7 +182,7 @@ def builder(sp, authnsign=False, wsign=False, valid_until=None, cache_duration=N
119182
str_contacts = '\n'.join(contacts_info)
120183

121184
metadata = """<?xml version="1.0"?>
122-
<md:EntityDescriptor xmlns:md="urn:oasis:names:tc:SAML:2.0:metadata"
185+
<md:EntityDescriptor xmlns:md="urn:oasis:names:tc:SAML:2.0:metadata" xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion"
123186
%(valid)s
124187
%(cache)s
125188
entityID="%(entity_id)s">
@@ -145,7 +208,14 @@ def builder(sp, authnsign=False, wsign=False, valid_until=None, cache_duration=N
145208
'organization': str_organization,
146209
'contacts': str_contacts,
147210
}
148-
return metadata
211+
212+
# i'm not sure why the above xml was build by hand. Building via lxml is way easier,
213+
# especially for conditional attributes etc..
214+
# So as a work around, i'm creating a xml dom, insert the attibute_consumer_service
215+
# nodes into it and then return the serialized xml
216+
root = etree.fromstring(metadata)
217+
OneLogin_Saml2_Metadata.add_attribute_consuming_service(root, attr_consuming_service)
218+
return etree.tostring(root, pretty_print=True)
149219

150220
@staticmethod
151221
def sign_metadata(metadata, key, cert, sign_algorithm=OneLogin_Saml2_Constants.RSA_SHA1):

src/onelogin/saml2/settings.py

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -257,6 +257,10 @@ def __add_default_values(self):
257257
if 'binding' not in self.__sp['assertionConsumerService'].keys():
258258
self.__sp['assertionConsumerService']['binding'] = OneLogin_Saml2_Constants.BINDING_HTTP_POST
259259

260+
# attributeConsumingService is optional
261+
if 'attributeConsumingService' not in self.__sp:
262+
self.__sp['attributeConsumingService'] = []
263+
260264
if 'singleLogoutService' not in self.__sp.keys():
261265
self.__sp['singleLogoutService'] = {}
262266
if 'binding' not in self.__sp['singleLogoutService']:
@@ -436,6 +440,51 @@ def check_sp_settings(self, settings):
436440
elif not validate_url(sp['assertionConsumerService']['url']):
437441
errors.append('sp_acs_url_invalid')
438442

443+
if 'attributeConsumingService' in sp and len(sp['attributeConsumingService']):
444+
# so we have a attributeConsumingService element...
445+
446+
# serviceName and requestedAttrib are required
447+
attribute_consuming_service = sp['attributeConsumingService']
448+
for attrib in attribute_consuming_service:
449+
if 'serviceName' not in attrib:
450+
errors.append('sp_attributeConsumingService_serviceName_not_found')
451+
if 'requestedAttributes' not in attrib:
452+
errors.append('sp_attributeConsumingService_requestedAttributes_not_found')
453+
454+
# verify that tags are of the correct types
455+
try:
456+
if type(attrib['isDefault']) != bool:
457+
errors.append('sp_attributeConsumingService_isDefault_type_invalid')
458+
except KeyError:
459+
# isDefault attribute is optional
460+
pass
461+
462+
if 'serviceName' in attrib and not isinstance(attrib['serviceName'], basestring):
463+
errors.append('sp_attributeConsumingService_serviceName_type_invalid')
464+
465+
try:
466+
if not isinstance(attrib['serviceDescription'], basestring):
467+
errors.append('sp_attributeConsumingService_serviceDescription_type_invalid')
468+
except KeyError:
469+
# serviceDescription attribute is optional
470+
pass
471+
472+
if 'requestedAttributes' in attrib:
473+
if type(attrib['requestedAttributes']) != list:
474+
errors.append('sp_attributeConsumingService_requestedAttributes_type_invalid')
475+
476+
for req_attrib in attrib['requestedAttributes']:
477+
if 'name' not in req_attrib:
478+
errors.append('sp_attributeConsumingService_requestedAttributes_name_not_found')
479+
if 'name' in req_attrib and not req_attrib['name'].strip():
480+
# name cannot be empty
481+
errors.append('sp_attributeConsumingService_requestedAttributes_name_invalid')
482+
if 'attributeValue' in req_attrib and type(req_attrib['attributeValue']) != list:
483+
errors.append('sp_attributeConsumingService_requestedAttributes_attributeValue_type_invalid')
484+
if 'isRequired' in req_attrib and type(req_attrib['isRequired']) != bool:
485+
errors.append('sp_attributeConsumingService_requestedAttributes_isRequired_type_invalid')
486+
487+
439488
if 'singleLogoutService' in sp and \
440489
'url' in sp['singleLogoutService'] and \
441490
len(sp['singleLogoutService']['url']) > 0 and \

tests/src/OneLogin/saml2_tests/authn_request_test.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -255,7 +255,7 @@ def testCreateEncSAMLRequest(self):
255255
inflated = decompress(decoded, -15)
256256

257257
self.assertRegexpMatches(inflated, '^<samlp:AuthnRequest')
258-
self.assertRegexpMatches(inflated, 'AssertionConsumerServiceURL="http://stuff.com/endpoints/endpoints/acs.php">')
258+
self.assertRegexpMatches(inflated, 'AssertionConsumerServiceURL="http://stuff.com/endpoints/endpoints/acs.php"(\s)*>')
259259
self.assertRegexpMatches(inflated, '<saml:Issuer>http://stuff.com/endpoints/metadata.php</saml:Issuer>')
260260
self.assertRegexpMatches(inflated, 'Format="urn:oasis:names:tc:SAML:2.0:nameid-format:encrypted"')
261261
self.assertRegexpMatches(inflated, 'ProviderName="SP prueba"')

0 commit comments

Comments
 (0)