Skip to content

Commit 1eb6f47

Browse files
committed
Support AttributeConsumingService
1 parent dec9834 commit 1eb6f47

9 files changed

Lines changed: 271 additions & 12 deletions

File tree

README.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -214,6 +214,22 @@ This is the settings.json file:
214214
// only for this endpoint.
215215
"binding": "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect"
216216
},
217+
// If you need to specify requested attributes, set a
218+
// attributeConsumingService. nameFormat, attributeValue and
219+
// friendlyName can be ommited
220+
"attributeConsumingService": {
221+
"ServiceName": "SP test",
222+
"serviceDescription": "Test Service",
223+
"requestedAttributes": [
224+
{
225+
"name": "",
226+
"isRequired": false,
227+
"nameFormat": "",
228+
"friendlyName": "",
229+
"attributeValue": ""
230+
}
231+
]
232+
},
217233
// Specifies the constraints on the name identifier to be used to
218234
// represent the requested subject.
219235
// Take a look on src/onelogin/saml2/constants.py to see the NameIdFormat that are supported.

src/onelogin/saml2/authn_request.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,10 @@ 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+
attr_consuming_service_str = "\n AttributeConsumingServiceIndex=\"1\""
94+
9195
request = OneLogin_Saml2_Templates.AUTHN_REQUEST % \
9296
{
9397
'id': uid,
@@ -100,6 +104,7 @@ def __init__(self, settings, force_authn=False, is_passive=False):
100104
'entity_id': sp_data['entityId'],
101105
'name_id_policy': name_id_policy_format,
102106
'requested_authn_context_str': requested_authn_context_str,
107+
'attr_consuming_service_str': attr_consuming_service_str,
103108
}
104109

105110
self.__authn_request = request

src/onelogin/saml2/metadata.py

Lines changed: 47 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ def builder(sp, authnsign=False, wsign=False, valid_until=None, cache_duration=N
5757
:param contacts: Contacts info
5858
:type contacts: dict
5959
60-
:param organization: Organization ingo
60+
:param organization: Organization info
6161
:type organization: dict
6262
"""
6363
if valid_until is None:
@@ -85,8 +85,7 @@ def builder(sp, authnsign=False, wsign=False, valid_until=None, cache_duration=N
8585

8686
sls = ''
8787
if 'singleLogoutService' in sp and 'url' in sp['singleLogoutService']:
88-
sls = """ <md:SingleLogoutService Binding="%(binding)s"
89-
Location="%(location)s" />\n""" % \
88+
sls = OneLogin_Saml2_Templates.MD_SLS % \
9089
{
9190
'binding': sp['singleLogoutService']['binding'],
9291
'location': sp['singleLogoutService']['url'],
@@ -105,9 +104,7 @@ def builder(sp, authnsign=False, wsign=False, valid_until=None, cache_duration=N
105104
organization_displaynames.append(""" <md:OrganizationDisplayName xml:lang="%s">%s</md:OrganizationDisplayName>""" % (lang, info['displayname']))
106105
organization_urls.append(""" <md:OrganizationURL xml:lang="%s">%s</md:OrganizationURL>""" % (lang, info['url']))
107106
org_data = '\n'.join(organization_names) + '\n' + '\n'.join(organization_displaynames) + '\n' + '\n'.join(organization_urls)
108-
str_organization = """ <md:Organization>
109-
%(org)s
110-
</md:Organization>""" % {'org': org_data}
107+
str_organization = """ <md:Organization>\n%(org)s\n </md:Organization>""" % {'org': org_data}
111108

112109
str_contacts = ''
113110
if len(contacts) > 0:
@@ -122,6 +119,49 @@ def builder(sp, authnsign=False, wsign=False, valid_until=None, cache_duration=N
122119
contacts_info.append(contact)
123120
str_contacts = '\n'.join(contacts_info)
124121

122+
str_attribute_consuming_service = ''
123+
124+
if 'attributeConsumingService' in sp and len(sp['attributeConsumingService']):
125+
attr_cs_desc_str = ''
126+
if "serviceDescription" in sp['attributeConsumingService']:
127+
attr_cs_desc_str = """ <md:ServiceDescription xml:lang="en">%s</md:ServiceDescription>\n""" % sp['attributeConsumingService']['serviceDescription']
128+
129+
requested_attribute_data = []
130+
for req_attribs in sp['attributeConsumingService']['requestedAttributes']:
131+
req_attr_nameformat_str = req_attr_friendlyname_str = req_attr_isrequired_str = ''
132+
req_attr_aux_str = ' \>'
133+
134+
if 'nameFormat' in req_attribs.keys() and req_attribs['nameFormat']:
135+
req_attr_nameformat_str = " NameFormat=\"%s\"" % req_attribs['nameFormat']
136+
if 'friendlyName' in req_attribs.keys() and req_attribs['friendlyName']:
137+
req_attr_nameformat_str = " FriendlyName=\"%s\"" % req_attribs['friendlyName']
138+
if 'isRequired' in req_attribs.keys() and req_attribs['isRequired']:
139+
req_attr_isrequired_str = " isRequired=\"%s\"" % req_attribs['isRequired']
140+
if 'attributeValue' in req_attribs.keys() and req_attribs['attributeValue']:
141+
req_attr_aux_str = """ >
142+
<saml:AttributeValue xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion>%(attributeValue)</saml:AttributeValue>
143+
</md:RequestedAttribute>""" % \
144+
{
145+
'attributeValue': req_attribs['attributeValue']
146+
}
147+
148+
requested_attribute = OneLogin_Saml2_Templates.MD_REQUESTED_ATTRIBUTE % \
149+
{
150+
'req_attr_name': req_attribs['name'],
151+
'req_attr_nameformat_str': req_attr_nameformat_str,
152+
'req_attr_friendlyname_str': req_attr_friendlyname_str,
153+
'req_attr_isrequired_str': req_attr_isrequired_str,
154+
'req_attr_aux_str': req_attr_aux_str
155+
}
156+
requested_attribute_data.append(requested_attribute)
157+
158+
str_attribute_consuming_service = OneLogin_Saml2_Templates.MD_ATTR_CONSUMER_SERVICE % \
159+
{
160+
'service_name': sp['attributeConsumingService']['serviceName'],
161+
'attr_cs_desc': attr_cs_desc_str,
162+
'requested_attribute_str': '\n'.join(requested_attribute_data)
163+
}
164+
125165
metadata = OneLogin_Saml2_Templates.MD_ENTITY_DESCRIPTOR % \
126166
{
127167
'valid': ('validUntil="%s"' % valid_until_str) if valid_until_str else '',
@@ -135,6 +175,7 @@ def builder(sp, authnsign=False, wsign=False, valid_until=None, cache_duration=N
135175
'sls': sls,
136176
'organization': str_organization,
137177
'contacts': str_contacts,
178+
'attribute_consuming_service': str_attribute_consuming_service
138179
}
139180

140181
return metadata

src/onelogin/saml2/settings.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -438,6 +438,31 @@ def check_sp_settings(self, settings):
438438
elif not validate_url(sp['assertionConsumerService']['url']):
439439
errors.append('sp_acs_url_invalid')
440440

441+
if 'attributeConsumingService' in sp and len(sp['attributeConsumingService']):
442+
attributeConsumingService = sp['attributeConsumingService']
443+
if 'serviceName' not in attributeConsumingService:
444+
errors.append('sp_attributeConsumingService_serviceName_not_found')
445+
elif not isinstance(attributeConsumingService['serviceName'], basestring):
446+
errors.append('sp_attributeConsumingService_serviceName_type_invalid')
447+
448+
if 'requestedAttributes' not in attributeConsumingService:
449+
errors.append('sp_attributeConsumingService_requestedAttributes_not_found')
450+
elif not isinstance(attributeConsumingService['requestedAttributes'], list):
451+
errors.append('sp_attributeConsumingService_serviceName_type_invalid')
452+
else:
453+
for req_attrib in attributeConsumingService['requestedAttributes']:
454+
if 'name' not in req_attrib:
455+
errors.append('sp_attributeConsumingService_requestedAttributes_name_not_found')
456+
if 'name' in req_attrib and not req_attrib['name'].strip():
457+
errors.append('sp_attributeConsumingService_requestedAttributes_name_invalid')
458+
if 'attributeValue' in req_attrib and type(req_attrib['attributeValue']) != list:
459+
errors.append('sp_attributeConsumingService_requestedAttributes_attributeValue_type_invalid')
460+
if 'isRequired' in req_attrib and type(req_attrib['isRequired']) != bool:
461+
errors.append('sp_attributeConsumingService_requestedAttributes_isRequired_type_invalid')
462+
463+
if "serviceDescription" in attributeConsumingService and not isinstance(attributeConsumingService['serviceDescription'], basestring):
464+
errors.append('sp_attributeConsumingService_serviceDescription_type_invalid')
465+
441466
if 'singleLogoutService' in sp and \
442467
'url' in sp['singleLogoutService'] and \
443468
len(sp['singleLogoutService']['url']) > 0 and \

src/onelogin/saml2/xml_templates.py

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,8 +31,9 @@ class OneLogin_Saml2_Templates(object):
3131
IssueInstant="%(issue_instant)s"
3232
Destination="%(destination)s"
3333
ProtocolBinding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST"
34-
AssertionConsumerServiceURL="%(assertion_url)s">
34+
AssertionConsumerServiceURL="%(assertion_url)s"%(attr_consuming_service_str)s>
3535
<saml:Issuer>%(entity_id)s</saml:Issuer>
36+
3637
<samlp:NameIDPolicy
3738
Format="%(name_id_policy)s"
3839
AllowCreate="true" />
@@ -73,6 +74,19 @@ class OneLogin_Saml2_Templates(object):
7374
<md:EmailAddress>%(email)s</md:EmailAddress>
7475
</md:ContactPerson>"""
7576

77+
MD_SLS = """\
78+
<md:SingleLogoutService Binding="%(binding)s"
79+
Location="%(location)s" />\n"""
80+
81+
MD_REQUESTED_ATTRIBUTE = """\
82+
<md:RequestedAttribute Name="%(req_attr_name)s"%(req_attr_nameformat_str)s%(req_attr_isrequired_str)s%(req_attr_aux_str)s"""
83+
84+
MD_ATTR_CONSUMER_SERVICE = """\
85+
<md:AttributeConsumingService index="1">
86+
<md:ServiceName xml:lang="en">%(service_name)s</md:ServiceName>
87+
%(attr_cs_desc)s%(requested_attribute_str)s
88+
</md:AttributeConsumingService>\n"""
89+
7690
MD_ENTITY_DESCRIPTOR = """\
7791
<?xml version="1.0"?>
7892
<md:EntityDescriptor xmlns:md="urn:oasis:names:tc:SAML:2.0:metadata"
@@ -84,7 +98,7 @@ class OneLogin_Saml2_Templates(object):
8498
<md:AssertionConsumerService Binding="%(binding)s"
8599
Location="%(location)s"
86100
index="1" />
87-
</md:SPSSODescriptor>
101+
%(attribute_consuming_service)s </md:SPSSODescriptor>
88102
%(organization)s
89103
%(contacts)s
90104
</md:EntityDescriptor>"""

tests/settings/settings4.json

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
{
2+
"strict": false,
3+
"debug": false,
4+
"custom_base_path": "../../../tests/data/customPath/",
5+
"sp": {
6+
"entityId": "http://pytoolkit.com:8000/metadata/",
7+
"assertionConsumerService": {
8+
"url": "http://pytoolkit.com:8000/?acs"
9+
},
10+
"attributeConsumingService": {
11+
"isDefault": false,
12+
"serviceName": "Test Service",
13+
"serviceDescription": "Test Service",
14+
"requestedAttributes": [ {
15+
"name": "urn:oid:2.5.4.42",
16+
"nameFormat": "urn:oasis:names:tc:SAML:2.0:attrname-format:uri",
17+
"friendlyName": "givenName",
18+
"isRequired": false
19+
},
20+
{
21+
"name": "urn:oid:2.5.4.4",
22+
"nameFormat": "urn:oasis:names:tc:SAML:2.0:attrname-format:uri",
23+
"friendlyName": "sn",
24+
"isRequired": false
25+
},
26+
{
27+
"name": "urn:oid:2.16.840.1.113730.3.1.241",
28+
"nameFormat": "urn:oasis:names:tc:SAML:2.0:attrname-format:uri",
29+
"friendlyName": "displayName",
30+
"isRequired": false
31+
},
32+
{
33+
"name": "urn:oid:0.9.2342.19200300.100.1.3",
34+
"nameFormat": "urn:oasis:names:tc:SAML:2.0:attrname-format:uri",
35+
"friendlyName": "mail",
36+
"isRequired": false
37+
},
38+
{
39+
"name": "urn:oid:0.9.2342.19200300.100.1.1",
40+
"nameFormat": "urn:oasis:names:tc:SAML:2.0:attrname-format:uri",
41+
"friendlyName": "uid",
42+
"isRequired": false
43+
}
44+
]
45+
},
46+
"singleLogoutService": {
47+
"url": "http://pytoolkit.com:8000/?sls"
48+
},
49+
"NameIDFormat": "urn:oasis:names:tc:SAML:2.0:nameid-format:unspecified"
50+
},
51+
"idp": {
52+
"entityId": "https://pitbulk.no-ip.org/simplesaml/saml2/idp/metadata.php",
53+
"singleSignOnService": {
54+
"url": "http://pitbulk.no-ip.org/SSOService.php"
55+
},
56+
"singleLogoutService": {
57+
"url": "http://pitbulk.no-ip.org/SingleLogoutService.php"
58+
},
59+
"x509cert": "MIICbDCCAdWgAwIBAgIBADANBgkqhkiG9w0BAQ0FADBTMQswCQYDVQQGEwJ1czETMBEGA1UECAwKQ2FsaWZvcm5pYTEVMBMGA1UECgwMT25lbG9naW4gSW5jMRgwFgYDVQQDDA9pZHAuZXhhbXBsZS5jb20wHhcNMTQwOTIzMTIyNDA4WhcNNDIwMjA4MTIyNDA4WjBTMQswCQYDVQQGEwJ1czETMBEGA1UECAwKQ2FsaWZvcm5pYTEVMBMGA1UECgwMT25lbG9naW4gSW5jMRgwFgYDVQQDDA9pZHAuZXhhbXBsZS5jb20wgZ8wDQYJKoZIhvcNAQEBBQADgY0AMIGJAoGBAOWA+YHU7cvPOrBOfxCscsYTJB+kH3MaA9BFrSHFS+KcR6cw7oPSktIJxUgvDpQbtfNcOkE/tuOPBDoech7AXfvH6d7Bw7xtW8PPJ2mB5Hn/HGW2roYhxmfh3tR5SdwN6i4ERVF8eLkvwCHsNQyK2Ref0DAJvpBNZMHCpS24916/AgMBAAGjUDBOMB0GA1UdDgQWBBQ77/qVeiigfhYDITplCNtJKZTM8DAfBgNVHSMEGDAWgBQ77/qVeiigfhYDITplCNtJKZTM8DAMBgNVHRMEBTADAQH/MA0GCSqGSIb3DQEBDQUAA4GBAJO2j/1uO80E5C2PM6Fk9mzerrbkxl7AZ/mvlbOn+sNZE+VZ1AntYuG8ekbJpJtG1YfRfc7EA9mEtqvv4dhv7zBy4nK49OR+KpIBjItWB5kYvrqMLKBa32sMbgqqUqeF1ENXKjpvLSuPdfGJZA3dNa/+Dyb8GGqWe707zLyc5F8m"
60+
},
61+
"security": {
62+
"authnRequestsSigned": false,
63+
"wantAssertionsSigned": false,
64+
"signMetadata": false
65+
},
66+
"contactPerson": {
67+
"technical": {
68+
"givenName": "technical_name",
69+
"emailAddress": "technical@example.com"
70+
},
71+
"support": {
72+
"givenName": "support_name",
73+
"emailAddress": "support@example.com"
74+
}
75+
},
76+
"organization": {
77+
"en-US": {
78+
"name": "sp_test",
79+
"displayname": "SP test",
80+
"url": "http://sp.example.com"
81+
}
82+
}
83+
}

tests/src/OneLogin/saml2_tests/authn_request_test.py

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,8 @@
2222

2323

2424
class OneLogin_Saml2_Authn_Request_Test(unittest.TestCase):
25-
def loadSettingsJSON(self):
26-
filename = join(dirname(__file__), '..', '..', '..', 'settings', 'settings1.json')
25+
def loadSettingsJSON(self, filename='settings1.json'):
26+
filename = join(dirname(__file__), '..', '..', '..', 'settings', filename)
2727
if exists(filename):
2828
stream = open(filename, 'r')
2929
settings = json.load(stream)
@@ -255,3 +255,24 @@ def testGetID(self):
255255
inflated = compat.to_string(OneLogin_Saml2_Utils.decode_base64_and_inflate(authn_request_encoded))
256256
document = OneLogin_Saml2_XML.to_etree(inflated)
257257
self.assertEqual(authn_request.get_id(), document.get('ID', None))
258+
259+
def testAttributeConsumingService(self):
260+
"""
261+
Tests that the attributeConsumingServiceIndex is present as an attribute
262+
"""
263+
saml_settings = self.loadSettingsJSON()
264+
settings = OneLogin_Saml2_Settings(saml_settings)
265+
266+
authn_request = OneLogin_Saml2_Authn_Request(settings)
267+
authn_request_encoded = authn_request.get_request()
268+
inflated = compat.to_string(OneLogin_Saml2_Utils.decode_base64_and_inflate(authn_request_encoded))
269+
270+
self.assertNotIn('AttributeConsumingServiceIndex="1"', inflated)
271+
272+
saml_settings = self.loadSettingsJSON('settings4.json')
273+
settings = OneLogin_Saml2_Settings(saml_settings)
274+
275+
authn_request = OneLogin_Saml2_Authn_Request(settings)
276+
authn_request_encoded = authn_request.get_request()
277+
inflated = compat.to_string(OneLogin_Saml2_Utils.decode_base64_and_inflate(authn_request_encoded))
278+
self.assertRegexpMatches(inflated, 'AttributeConsumingServiceIndex="1"')

tests/src/OneLogin/saml2_tests/metadata_test.py

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,8 @@
1515

1616

1717
class OneLogin_Saml2_Metadata_Test(unittest.TestCase):
18-
def loadSettingsJSON(self):
19-
filename = join(dirname(__file__), '..', '..', '..', 'settings', 'settings1.json')
18+
def loadSettingsJSON(self, filename='settings1.json'):
19+
filename = join(dirname(__file__), '..', '..', '..', 'settings', filename)
2020
if exists(filename):
2121
stream = open(filename, 'r')
2222
settings = json.load(stream)
@@ -139,6 +139,28 @@ def testBuilder(self):
139139
parsed_datetime = strftime(r'%Y-%m-%dT%H:%M:%SZ', datetime_value.timetuple())
140140
self.assertIn('validUntil="%s"' % parsed_datetime, metadata6)
141141

142+
def testBuilderAttributeConsumingService(self):
143+
settings = OneLogin_Saml2_Settings(self.loadSettingsJSON('settings4.json'))
144+
sp_data = settings.get_sp_data()
145+
security = settings.get_security_data()
146+
organization = settings.get_organization()
147+
contacts = settings.get_contacts()
148+
149+
metadata = OneLogin_Saml2_Metadata.builder(
150+
sp_data, security['authnRequestsSigned'],
151+
security['wantAssertionsSigned'], None, None, contacts,
152+
organization
153+
)
154+
self.assertIn(""" <md:AttributeConsumingService index="1">
155+
<md:ServiceName xml:lang="en">Test Service</md:ServiceName>
156+
<md:ServiceDescription xml:lang="en">Test Service</md:ServiceDescription>
157+
<md:RequestedAttribute Name="urn:oid:2.5.4.42" FriendlyName="givenName" \>
158+
<md:RequestedAttribute Name="urn:oid:2.5.4.4" FriendlyName="sn" \>
159+
<md:RequestedAttribute Name="urn:oid:2.16.840.1.113730.3.1.241" FriendlyName="displayName" \>
160+
<md:RequestedAttribute Name="urn:oid:0.9.2342.19200300.100.1.3" FriendlyName="mail" \>
161+
<md:RequestedAttribute Name="urn:oid:0.9.2342.19200300.100.1.1" FriendlyName="uid" \>
162+
</md:AttributeConsumingService>""", metadata)
163+
142164
def testSignMetadata(self):
143165
"""
144166
Tests the signMetadata method of the OneLogin_Saml2_Metadata

0 commit comments

Comments
 (0)