diff options
-rw-r--r-- | doc/howto/config.rst | 246 | ||||
-rwxr-xr-x | example/sp-wsgi/sp.py | 16 | ||||
-rwxr-xr-x | setup.py | 13 | ||||
-rw-r--r-- | src/saml2/attributemaps/saml_uri.py | 17 | ||||
-rw-r--r-- | src/saml2/client_base.py | 66 | ||||
-rw-r--r-- | src/saml2/config.py | 3 | ||||
-rw-r--r-- | src/saml2/extension/requested_attributes.py | 131 | ||||
-rw-r--r-- | src/saml2/extension/sp_type.py | 54 | ||||
-rw-r--r-- | src/saml2/httputil.py | 3 | ||||
-rw-r--r-- | src/saml2/metadata.py | 12 | ||||
-rw-r--r-- | src/saml2/response.py | 3 | ||||
-rw-r--r-- | tests/server_conf.py | 13 | ||||
-rw-r--r-- | tests/sp_mdext_conf.py | 2 | ||||
-rw-r--r-- | tests/test_19_attribute_converter.py | 95 | ||||
-rw-r--r-- | tests/test_40_sigver.py | 29 | ||||
-rw-r--r-- | tests/test_51_client.py | 16 | ||||
-rw-r--r-- | tests/test_60_sp.py | 9 | ||||
-rw-r--r-- | tests/test_83_md_extensions.py | 12 | ||||
-rw-r--r-- | tools/data/requested_attributes.xsd | 28 | ||||
-rw-r--r-- | tools/data/sp_type.xsd | 16 |
20 files changed, 700 insertions, 84 deletions
diff --git a/doc/howto/config.rst b/doc/howto/config.rst index 1819f6f4..c1691119 100644 --- a/doc/howto/config.rst +++ b/doc/howto/config.rst @@ -61,7 +61,7 @@ attribute_map_dir Format:: "attribute_map_dir": "attribute-maps" - + Points to a directory which has the attribute maps in Python modules. A typical map file will looks like this:: @@ -91,9 +91,9 @@ The *to* and *fro* sub-dictionaries then contain the mapping between the names. As you see the format is again a python dictionary where the key is the name to convert from, and the value is the name to convert to. - -Since *to* in most cases is the inverse of the *fro* file, the -software allowes you to only specify one of them and it will + +Since *to* in most cases is the inverse of the *fro* file, the +software allowes you to only specify one of them and it will automatically create the other. cert_file @@ -109,11 +109,11 @@ This is the public part of the service private/public key pair. contact_person ^^^^^^^^^^^^^^ -This is only used by *make_metadata.py* when it constructs the metadata for +This is only used by *make_metadata.py* when it constructs the metadata for the service described by the configuration file. This is where you describe who can be contacted if questions arise about the service or if support is needed. The possible types are according to -the standard **technical**, **support**, **administrative**, **billing** +the standard **technical**, **support**, **administrative**, **billing** and **other**.:: contact_person: [{ @@ -179,9 +179,9 @@ a file accessible on the server the service runs on, or somewhere on the net.:: }], }, -The above configuration means that the service should read two local +The above configuration means that the service should read two local metadata files, and on top of that load one from the net. To verify the -authenticity of the file downloaded from the net, the local copy of the +authenticity of the file downloaded from the net, the local copy of the public key should be used. This public key must be acquired by some out-of-band method. @@ -198,16 +198,49 @@ Where you describe the organization responsible for the service.:: } .. note:: You can specify the language of the name, or the language used on - the webpage, by entering a tuple, instead of a simple string, + the webpage, by entering a tuple, instead of a simple string, where the second part is the language code. If you don't specify a language the default is "en" (English). +preferred_binding +^^^^^^^^^^^^^^^^^ + +Which binding should be prefered for a service. +Example configuration:: + + "preferred_binding" = { + "single_sign_on_service": [ + 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect', + 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST', + 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Artifact', + ], + "single_logout_service": [ + 'urn:oasis:names:tc:SAML:2.0:bindings:SOAP', + 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect', + 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST', + 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Artifact', + ], + } + +The available services are: + +* manage_name_id_service +* assertion_consumer_service +* name_id_mapping_service +* authn_query_service +* attribute_service +* authz_service +* assertion_id_request_service +* artifact_resolution_service +* attribute_consuming_service + + service ^^^^^^^ -Which services the server will provide; those are combinations of "idp", "sp" +Which services the server will provide; those are combinations of "idp", "sp" and "aa". -So if a server is a Service Provider (SP) then the configuration +So if a server is a Service Provider (SP) then the configuration could look something like this:: "service": { @@ -225,7 +258,7 @@ could look something like this:: }, } }, - + There are two options common to all services: 'name' and 'endpoints'. The remaining options are specific to one or the other of the service types. Which one is specified along side the name of the option. @@ -312,17 +345,17 @@ An example might be:: } } } - -*lifetime* - This is the maximum amount of time before the information should be - regarded as stale. In an Assertion this is represented in the NotOnOrAfter - attribute. + +*lifetime* + This is the maximum amount of time before the information should be + regarded as stale. In an Assertion this is represented in the NotOnOrAfter + attribute. *attribute_restrictions* By default there is no restrictions as to which attributes should be - return. Instead all the attributes and values that are gathered by the + return. Instead all the attributes and values that are gathered by the database backends will be returned if nothing else is stated. In the example above the SP with the entity identifier - "urn:mace:umu.se:saml:roland:sp" + "urn:mace:umu.se:saml:roland:sp" has an attribute restriction: only the attributes 'givenName' and 'surName' are to be returned. There is no limitations as to what values on these attributes that can be returned. @@ -332,7 +365,7 @@ An example might be:: the friendly name, and the saml attribute name will be taken from the uri/oid defined in the attribute map. -If restrictions on values are deemed necessary those are represented by +If restrictions on values are deemed necessary those are represented by regular expressions.:: "service": { @@ -375,12 +408,161 @@ Example:: } +want_response_signed +"""""""""""""""""""" + +Indicates that Authentication Responses to this SP must be signed. If set to +True, the SP will not consume any SAML Responses that are not signed. + +Example:: + + "service": { + "sp": { + "want_response_signed": True, + } + } + + +force_authn +""""""""""" + +Mandates that the identity provider MUST authenticate the presenter directly +rather than rely on a previous security context. + +Example:: + + "service": { + "sp": { + "force_authn": True, + } + } + + +allow_unsolicited +""""""""""""""""" + +When set to true, the SP will consume unsolicited SAML Responses, i.e. SAML +Responses for which it has not sent a respective SAML Authentication Request. + +Example:: + + "service": { + "sp": { + "allow_unsolicited": True, + } + } + + +hide_assertion_consumer_service +""""""""""""""""""""""""""""""" + +When set to true the AuthnRequest will not include the +AssertionConsumerServiceURL and ProtocolBinding attributes. + +Example:: + + "service": { + "sp": { + "hide_assertion_consumer_service": True, + } + } + +This kind of functionality is required for the eIDAS SAML profile + +> eIDAS-Connectors SHOULD NOT provide AssertionConsumerServiceURL. + +.. note:: + This is relevant only for the eIDAS SAML profile. + + +sp_type +""""""" + +Sets the value for the eIDAS SPType node. By the eIDAS specification the value +can be one of *public* and *private*. + +Example:: + + "service": { + "sp": { + "sp_type": "private", + } + } + +.. note:: + This is relevant only for the eIDAS SAML profile. + + +sp_type_in_metadata +""""""""""""""""""" + +Whether the SPType node should appear in the metadata document +or as part of each AuthnRequest. + +Example:: + + "service": { + "sp": { + "sp_type_in_metadata": True, + } + } + +.. note:: + This is relevant only for the eIDAS SAML profile. + + +requested_attributes +"""""""""""""""""""" + +A list of attributes that the SP requires from an eIDAS-Service (IdP). +Each attribute is an object with the following attributes: + +* friendly_name +* name +* required +* name_format + +Where friendly_name is an attribute name such as *DateOfBirth*, name is the +full attribute name such as +*http://eidas.europa.eu/attributes/naturalperson/DateOfBirth*, required +indicates whether this attributed is required for authentication, and +name_format indicates the name format for that attribute, such as +*urn:oasis:names:tc:SAML:2.0:attrname-format:uri*. + +It is mandatory that at least name or friendly_name is set. +By default attributes are assumed to be required. +Missing attributes are infered based on the attribute maps data. + +Example:: + + "service": { + "sp": { + "requested_attributes": [ + { + "name": "http://eidas.europa.eu/attributes/naturalperson/PersonIdentifier", + }, + { + "friendly_name": "DateOfBirth", + "required": False, + }, + ], + } + } + +.. note:: + This is relevant only for the eIDAS SAML profile. + + This option is different from the required_attributes and + optional_attributes parameters that control the requested + attributes in the metadata of an SP. + + idp """ Defines the set of IdPs that this SP is allowed to use; if unset, all listed IdPs may be used. If set, then the value is expected to be a list with entity -identifiers for the allowed IdPs. +identifiers for the allowed IdPs. A typical configuration, when the allowed set of IdPs are limited, would look something like this:: @@ -404,7 +586,7 @@ Example:: "optional_attributes": ["title"], } } - + Since the attribute names used here are the user friendly ones an attribute map must exist, so that the server can use the full name when communicating with other servers. @@ -422,7 +604,7 @@ Example:: } } -Again as for *optional_attributes* the names given are expected to be +Again as for *optional_attributes* the names given are expected to be the user friendly names. want_assertions_signed @@ -444,7 +626,7 @@ Example:: idp/aa/sp -^^^^^^^^^ +^^^^^^^^^ If the configuration is covering both two or three different service types (like if one server is actually acting as both an IdP and a SP) then in some @@ -516,7 +698,7 @@ Example:: subject_data """""""""""" -The name of a database where the map between a local identifier and +The name of a database where the map between a local identifier and a distributed identifier is kept. By default this is a shelve database. So if you just specify name, then a shelve database with that name is created. On the other hand if you specify a tuple then the first @@ -548,8 +730,8 @@ Gives information about common identifiers for virtual_organizations:: }, Keys in this dictionary are the identifiers for the virtual organizations. -The arguments per organization are 'nameid_format' and 'common_identifier'. -Useful if all the IdPs and AAs that are involved in a virtual organization +The arguments per organization are 'nameid_format' and 'common_identifier'. +Useful if all the IdPs and AAs that are involved in a virtual organization have common attribute values for users that are part of the VO. Complete example @@ -622,9 +804,9 @@ A slightly more complex configuration:: "key_file" : "./mykey.pem", "cert_file" : "./mycert.pem", "xmlsec_binary" : "/usr/local/bin/xmlsec1", - "metadata" : { + "metadata" : { "local": ["example.xml"], - "remote": [{ + "remote": [{ "url":"https://kalmar2.org/simplesaml/module.php/aggregator/?id=kalmarcentral2&set=saml2", "cert":"kalmar2.pem"}] }, @@ -640,9 +822,9 @@ A slightly more complex configuration:: "type": "technical", }] } - -Uses metadata files, both local and remote, and will talk to whatever -IdP that appears in any of the metadata files. + +Uses metadata files, both local and remote, and will talk to whatever +IdP that appears in any of the metadata files. Other considerations :::::::::::::::::::: diff --git a/example/sp-wsgi/sp.py b/example/sp-wsgi/sp.py index 38a737ee..be1e8e68 100755 --- a/example/sp-wsgi/sp.py +++ b/example/sp-wsgi/sp.py @@ -106,21 +106,21 @@ def handle_static(environ, start_response, path): :return: wsgi response for the static file. """ try: - text = open(path).read() + data = open(path, 'rb').read() if path.endswith(".ico"): - resp = Response(text, headers=[('Content-Type', "image/x-icon")]) + resp = Response(data, headers=[('Content-Type', "image/x-icon")]) elif path.endswith(".html"): - resp = Response(text, headers=[('Content-Type', 'text/html')]) + resp = Response(data, headers=[('Content-Type', 'text/html')]) elif path.endswith(".txt"): - resp = Response(text, headers=[('Content-Type', 'text/plain')]) + resp = Response(data, headers=[('Content-Type', 'text/plain')]) elif path.endswith(".css"): - resp = Response(text, headers=[('Content-Type', 'text/css')]) + resp = Response(data, headers=[('Content-Type', 'text/css')]) elif path.endswith(".js"): - resp = Response(text, headers=[('Content-Type', 'text/javascript')]) + resp = Response(data, headers=[('Content-Type', 'text/javascript')]) elif path.endswith(".png"): - resp = Response(text, headers=[('Content-Type', 'image/png')]) + resp = Response(data, headers=[('Content-Type', 'image/png')]) else: - resp = Response(text) + resp = Response(data) except IOError: resp = NotFound() return resp(environ, start_response) @@ -8,12 +8,8 @@ from setuptools.command.test import test as TestCommand install_requires = [ # core dependencies - 'decorator', 'requests >= 1.0.0', 'future', - 'paste', - 'zope.interface', - 'repoze.who', 'cryptography', 'pytz', 'pyOpenSSL', @@ -22,6 +18,14 @@ install_requires = [ 'six' ] +extras_require = { + 's2repoze': [ + 'paste', + 'zope.interface', + 'repoze.who' + ] +} + version = '' with open('src/saml2/__init__.py', 'r') as fd: version = re.search(r'^__version__\s*=\s*[\'"]([^\'"]*)[\'"]', @@ -56,5 +60,6 @@ setup( scripts=["tools/parse_xsd2.py", "tools/make_metadata.py", "tools/mdexport.py", "tools/merge_metadata.py"], install_requires=install_requires, + extras_require=extras_require, zip_safe=False, ) diff --git a/src/saml2/attributemaps/saml_uri.py b/src/saml2/attributemaps/saml_uri.py index ca6dfd84..e97090ff 100644 --- a/src/saml2/attributemaps/saml_uri.py +++ b/src/saml2/attributemaps/saml_uri.py @@ -13,10 +13,19 @@ SCHAC = 'urn:oid:1.3.6.1.4.1.25178.1.2.' SIS = 'urn:oid:1.2.752.194.10.2.' UMICH = 'urn:oid:1.3.6.1.4.1.250.1.57.' OPENOSI_OID = 'urn:oid:1.3.6.1.4.1.27630.2.1.1.' #openosi-0.82.schema http://www.openosi.org/osi/display/ldap/Home +EIDAS_NATURALPERSON = 'http://eidas.europa.eu/attributes/naturalperson/' MAP = { 'identifier': 'urn:oasis:names:tc:SAML:2.0:attrname-format:uri', 'fro': { + EIDAS_NATURALPERSON+'PersonIdentifier': 'PersonIdentifier', + EIDAS_NATURALPERSON+'FamilyName': 'FamilyName', + EIDAS_NATURALPERSON+'FirstName': 'FirstName', + EIDAS_NATURALPERSON+'DateOfBirth': 'DateOfBirth', + EIDAS_NATURALPERSON+'BirthName': 'BirthName', + EIDAS_NATURALPERSON+'PlaceOfBirth': 'PlaceOfBirth', + EIDAS_NATURALPERSON+'CurrentAddress': 'CurrentAddress', + EIDAS_NATURALPERSON+'Gender': 'Gender', EDUCOURSE_OID+'1': 'eduCourseOffering', EDUCOURSE_OID+'2': 'eduCourseMember', EDUMEMBER1_OID+'1': 'isMemberOf', @@ -161,6 +170,14 @@ MAP = { X500ATTR_OID+'65': 'pseudonym', }, 'to': { + 'PersonIdentifier': EIDAS_NATURALPERSON+'PersonIdentifier', + 'FamilyName': EIDAS_NATURALPERSON+'FamilyName', + 'FirstName': EIDAS_NATURALPERSON+'FirstName', + 'DateOfBirth': EIDAS_NATURALPERSON+'DateOfBirth', + 'BirthName': EIDAS_NATURALPERSON+'BirthName', + 'PlaceOfBirth': EIDAS_NATURALPERSON+'PlaceOfBirth', + 'CurrentAddress': EIDAS_NATURALPERSON+'CurrentAddress', + 'Gender': EIDAS_NATURALPERSON+'Gender', 'associatedDomain': UCL_DIR_PILOT+'37', 'authorityRevocationList': X500ATTR_OID+'38', 'businessCategory': X500ATTR_OID+'15', diff --git a/src/saml2/client_base.py b/src/saml2/client_base.py index 8c607df6..f8704c20 100644 --- a/src/saml2/client_base.py +++ b/src/saml2/client_base.py @@ -10,6 +10,8 @@ import six from saml2.entity import Entity +import saml2.attributemaps as attributemaps + from saml2.mdstore import destinations from saml2.profile import paos, ecp from saml2.saml import NAMEID_FORMAT_TRANSIENT @@ -18,6 +20,9 @@ from saml2.samlp import NameIDMappingRequest from saml2.samlp import AttributeQuery from saml2.samlp import AuthzDecisionQuery from saml2.samlp import AuthnRequest +from saml2.samlp import Extensions +from saml2.extension import sp_type +from saml2.extension import requested_attributes import saml2 import time @@ -360,6 +365,67 @@ class Base(Entity): if force_authn: args['force_authn'] = 'true' + conf_sp_type = self.config.getattr('sp_type', 'sp') + conf_sp_type_in_md = self.config.getattr('sp_type_in_metadata', 'sp') + if conf_sp_type and conf_sp_type_in_md is False: + if not extensions: + extensions = Extensions() + item = sp_type.SPType(text=conf_sp_type) + extensions.add_extension_element(item) + + requested_attrs = self.config.getattr('requested_attributes', 'sp') + if requested_attrs: + if not extensions: + extensions = Extensions() + + attributemapsmods = [] + for modname in attributemaps.__all__: + attributemapsmods.append(getattr(attributemaps, modname)) + + items = [] + for attr in requested_attrs: + friendly_name = attr.get('friendly_name') + name = attr.get('name') + name_format = attr.get('name_format') + is_required = str(attr.get('required', False)).lower() + + if not name and not friendly_name: + raise ValueError( + "Missing required attribute: '{}' or '{}'".format( + 'name', 'friendly_name')) + + if not name: + for mod in attributemapsmods: + try: + name = mod.MAP['to'][friendly_name] + except KeyError: + continue + else: + if not name_format: + name_format = mod.MAP['identifier'] + break + + if not friendly_name: + for mod in attributemapsmods: + try: + friendly_name = mod.MAP['fro'][name] + except KeyError: + continue + else: + if not name_format: + name_format = mod.MAP['identifier'] + break + + items.append(requested_attributes.RequestedAttribute( + is_required=is_required, + name_format=name_format, + friendly_name=friendly_name, + name=name)) + + item = requested_attributes.RequestedAttributes( + extension_elements=items) + extensions.add_extension_element(item) + if kwargs: _args, extensions = self._filter_args(AuthnRequest(), extensions, **kwargs) diff --git a/src/saml2/config.py b/src/saml2/config.py index e508a954..296f0e85 100644 --- a/src/saml2/config.py +++ b/src/saml2/config.py @@ -78,6 +78,9 @@ SP_ARGS = [ "requested_attribute_name_format", "hide_assertion_consumer_service", "force_authn", + "sp_type", + "sp_type_in_metadata", + "requested_attributes", ] AA_IDP_ARGS = [ diff --git a/src/saml2/extension/requested_attributes.py b/src/saml2/extension/requested_attributes.py new file mode 100644 index 00000000..3d574f15 --- /dev/null +++ b/src/saml2/extension/requested_attributes.py @@ -0,0 +1,131 @@ +#!/usr/bin/env python + +# +# Generated Tue Jul 18 14:58:29 2017 by parse_xsd.py version 0.5. +# + +import saml2 +from saml2 import SamlBase + +from saml2 import saml + + +NAMESPACE = 'http://eidas.europa.eu/saml-extensions' + +class RequestedAttributeType_(SamlBase): + """The http://eidas.europa.eu/saml-extensions:RequestedAttributeType element """ + + c_tag = 'RequestedAttributeType' + c_namespace = NAMESPACE + c_children = SamlBase.c_children.copy() + c_attributes = SamlBase.c_attributes.copy() + c_child_order = SamlBase.c_child_order[:] + c_cardinality = SamlBase.c_cardinality.copy() + c_children['{urn:oasis:names:tc:SAML:2.0:assertion}AttributeValue'] = ('attribute_value', [saml.AttributeValue]) + c_cardinality['attribute_value'] = {"min":0} + c_attributes['Name'] = ('name', 'None', True) + c_attributes['NameFormat'] = ('name_format', 'None', True) + c_attributes['FriendlyName'] = ('friendly_name', 'None', False) + c_attributes['isRequired'] = ('is_required', 'None', False) + c_child_order.extend(['attribute_value']) + + def __init__(self, + attribute_value=None, + name=None, + name_format=None, + friendly_name=None, + is_required=None, + text=None, + extension_elements=None, + extension_attributes=None, + ): + SamlBase.__init__(self, + text=text, + extension_elements=extension_elements, + extension_attributes=extension_attributes, + ) + self.attribute_value=attribute_value or [] + self.name=name + self.name_format=name_format + self.friendly_name=friendly_name + self.is_required=is_required + +def requested_attribute_type__from_string(xml_string): + return saml2.create_class_from_xml_string(RequestedAttributeType_, xml_string) + + +class RequestedAttribute(RequestedAttributeType_): + """The http://eidas.europa.eu/saml-extensions:RequestedAttribute element """ + + c_tag = 'RequestedAttribute' + c_namespace = NAMESPACE + c_children = RequestedAttributeType_.c_children.copy() + c_attributes = RequestedAttributeType_.c_attributes.copy() + c_child_order = RequestedAttributeType_.c_child_order[:] + c_cardinality = RequestedAttributeType_.c_cardinality.copy() + +def requested_attribute_from_string(xml_string): + return saml2.create_class_from_xml_string(RequestedAttribute, xml_string) + + +class RequestedAttributesType_(SamlBase): + """The http://eidas.europa.eu/saml-extensions:RequestedAttributesType element """ + + c_tag = 'RequestedAttributesType' + c_namespace = NAMESPACE + c_children = SamlBase.c_children.copy() + c_attributes = SamlBase.c_attributes.copy() + c_child_order = SamlBase.c_child_order[:] + c_cardinality = SamlBase.c_cardinality.copy() + c_children['{http://eidas.europa.eu/saml-extensions}RequestedAttribute'] = ('requested_attribute', [RequestedAttribute]) + c_cardinality['requested_attribute'] = {"min":0} + c_child_order.extend(['requested_attribute']) + + def __init__(self, + requested_attribute=None, + text=None, + extension_elements=None, + extension_attributes=None, + ): + SamlBase.__init__(self, + text=text, + extension_elements=extension_elements, + extension_attributes=extension_attributes, + ) + self.requested_attribute=requested_attribute or [] + +def requested_attributes_type__from_string(xml_string): + return saml2.create_class_from_xml_string(RequestedAttributesType_, xml_string) + + +class RequestedAttributes(RequestedAttributesType_): + """The http://eidas.europa.eu/saml-extensions:RequestedAttributes element """ + + c_tag = 'RequestedAttributes' + c_namespace = NAMESPACE + c_children = RequestedAttributesType_.c_children.copy() + c_attributes = RequestedAttributesType_.c_attributes.copy() + c_child_order = RequestedAttributesType_.c_child_order[:] + c_cardinality = RequestedAttributesType_.c_cardinality.copy() + +def requested_attributes_from_string(xml_string): + return saml2.create_class_from_xml_string(RequestedAttributes, xml_string) + + +ELEMENT_FROM_STRING = { + RequestedAttributes.c_tag: requested_attributes_from_string, + RequestedAttributesType_.c_tag: requested_attributes_type__from_string, + RequestedAttribute.c_tag: requested_attribute_from_string, + RequestedAttributeType_.c_tag: requested_attribute_type__from_string, +} + +ELEMENT_BY_TAG = { + 'RequestedAttributes': RequestedAttributes, + 'RequestedAttributesType': RequestedAttributesType_, + 'RequestedAttribute': RequestedAttribute, + 'RequestedAttributeType': RequestedAttributeType_, +} + + +def factory(tag, **kwargs): + return ELEMENT_BY_TAG[tag](**kwargs) diff --git a/src/saml2/extension/sp_type.py b/src/saml2/extension/sp_type.py new file mode 100644 index 00000000..8ffb2cea --- /dev/null +++ b/src/saml2/extension/sp_type.py @@ -0,0 +1,54 @@ +#!/usr/bin/env python + +# +# Generated Tue Jul 18 15:03:44 2017 by parse_xsd.py version 0.5. +# + +import saml2 +from saml2 import SamlBase + + +NAMESPACE = 'http://eidas.europa.eu/saml-extensions' + +class SPTypeType_(SamlBase): + """The http://eidas.europa.eu/saml-extensions:SPTypeType element """ + + c_tag = 'SPTypeType' + c_namespace = NAMESPACE + c_value_type = {'base': 'xsd:string', 'enumeration': ['public', 'private']} + c_children = SamlBase.c_children.copy() + c_attributes = SamlBase.c_attributes.copy() + c_child_order = SamlBase.c_child_order[:] + c_cardinality = SamlBase.c_cardinality.copy() + +def sp_type_type__from_string(xml_string): + return saml2.create_class_from_xml_string(SPTypeType_, xml_string) + + +class SPType(SPTypeType_): + """The http://eidas.europa.eu/saml-extensions:SPType element """ + + c_tag = 'SPType' + c_namespace = NAMESPACE + c_children = SPTypeType_.c_children.copy() + c_attributes = SPTypeType_.c_attributes.copy() + c_child_order = SPTypeType_.c_child_order[:] + c_cardinality = SPTypeType_.c_cardinality.copy() + +def sp_type_from_string(xml_string): + return saml2.create_class_from_xml_string(SPType, xml_string) + + +ELEMENT_FROM_STRING = { + SPType.c_tag: sp_type_from_string, + SPTypeType_.c_tag: sp_type_type__from_string, +} + +ELEMENT_BY_TAG = { + 'SPType': SPType, + 'SPTypeType': SPTypeType_, +} + + +def factory(tag, **kwargs): + return ELEMENT_BY_TAG[tag](**kwargs) diff --git a/src/saml2/httputil.py b/src/saml2/httputil.py index 0901f7b0..0e7f32a6 100644 --- a/src/saml2/httputil.py +++ b/src/saml2/httputil.py @@ -62,6 +62,9 @@ class Response(object): return [mte.render(**argv)] else: if isinstance(message, six.string_types): + # Note(JP): A WSGI app should always respond + # with bytes, so at this point the message should + # become encoded instead of passing a text object. return [message] elif isinstance(message, six.binary_type): return [message] diff --git a/src/saml2/metadata.py b/src/saml2/metadata.py index 50ec0bae..de2e6e75 100644 --- a/src/saml2/metadata.py +++ b/src/saml2/metadata.py @@ -9,6 +9,7 @@ from saml2.extension import mdui from saml2.extension import idpdisc from saml2.extension import shibmd from saml2.extension import mdattr +from saml2.extension import sp_type from saml2.saml import NAME_FORMAT_URI from saml2.saml import AttributeValue from saml2.saml import Attribute @@ -722,7 +723,8 @@ def entity_descriptor(confd): entd.contact_person = do_contact_person_info(confd.contact_person) if confd.entity_category: - entd.extensions = md.Extensions() + if not entd.extensions: + entd.extensions = md.Extensions() ava = [AttributeValue(text=c) for c in confd.entity_category] attr = Attribute(attribute_value=ava, name="http://macedir.org/entity-category") @@ -734,6 +736,14 @@ def entity_descriptor(confd): entd.extensions = md.Extensions() entd.extensions.add_extension_element(item) + conf_sp_type = confd.getattr('sp_type', 'sp') + conf_sp_type_in_md = confd.getattr('sp_type_in_metadata', 'sp') + if conf_sp_type and conf_sp_type_in_md is True: + if not entd.extensions: + entd.extensions = md.Extensions() + item = sp_type.SPType(text=conf_sp_type) + entd.extensions.add_extension_element(item) + serves = confd.serves if not serves: raise SAMLError( diff --git a/src/saml2/response.py b/src/saml2/response.py index 5ca75bf1..6de8723b 100644 --- a/src/saml2/response.py +++ b/src/saml2/response.py @@ -910,7 +910,8 @@ class AuthnResponse(StatusResponse): else: # This is a saml2int limitation try: assert len(self.response.assertion) == 1 or \ - len(self.response.encrypted_assertion) == 1 + len(self.response.encrypted_assertion) == 1 or \ + self.assertion is not None except AssertionError: raise Exception("No assertion part") diff --git a/tests/server_conf.py b/tests/server_conf.py index aa34d8f7..4b528119 100644 --- a/tests/server_conf.py +++ b/tests/server_conf.py @@ -14,6 +14,19 @@ CONFIG = { "required_attributes": ["surName", "givenName", "mail"], "optional_attributes": ["title"], "idp": ["urn:mace:example.com:saml:roland:idp"], + "requested_attributes": [ + { + "name": "http://eidas.europa.eu/attributes/naturalperson/DateOfBirth", + "required": False, + }, + { + "friendly_name": "PersonIdentifier", + "required": True, + }, + { + "friendly_name": "PlaceOfBirth", + }, + ], } }, "debug": 1, diff --git a/tests/sp_mdext_conf.py b/tests/sp_mdext_conf.py index 67e33414..b1f0cf42 100644 --- a/tests/sp_mdext_conf.py +++ b/tests/sp_mdext_conf.py @@ -6,6 +6,8 @@ CONFIG = { "description": "My own SP", "service": { "sp": { + "sp_type": "public", + "sp_type_in_metadata": True, "endpoints": { "assertion_consumer_service": [ "http://lingon.catalogix.se:8087/"], diff --git a/tests/test_19_attribute_converter.py b/tests/test_19_attribute_converter.py index 0fa807b7..8662feee 100644 --- a/tests/test_19_attribute_converter.py +++ b/tests/test_19_attribute_converter.py @@ -10,6 +10,7 @@ from saml2.attribute_converter import AttributeConverter from saml2.attribute_converter import to_local from saml2.saml import attribute_from_string, name_id_from_string, NameID, NAMEID_FORMAT_PERSISTENT from saml2.saml import attribute_statement_from_string +import saml2.attributemaps.saml_uri as saml_map def _eq(l1, l2): @@ -139,12 +140,14 @@ class TestAC(): def test_to_local_name_from_unspecified(self): _xml = """<?xml version='1.0' encoding='UTF-8'?> <ns0:AttributeStatement xmlns:ns0="urn:oasis:names:tc:SAML:2.0:assertion"> -<ns0:Attribute - xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" - Name="EmailAddress" - NameFormat="urn:oasis:names:tc:SAML:2.0:attrname-format:unspecified"> - <ns0:AttributeValue xsi:type="xs:string">foo@bar.com</ns0:AttributeValue> -</ns0:Attribute></ns0:AttributeStatement>""" + <ns0:Attribute + xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + Name="EmailAddress" + NameFormat="urn:oasis:names:tc:SAML:2.0:attrname-format:unspecified"> + <ns0:AttributeValue xsi:type="xs:string">foo@bar.com</ns0:AttributeValue> + </ns0:Attribute> + </ns0:AttributeStatement> + """ attr = attribute_statement_from_string(_xml) ava = attribute_converter.to_local(self.acs, attr) @@ -236,26 +239,70 @@ def test_noop_attribute_conversion(): assert attr.attribute_value[0].text == "Roland" -ava = """<?xml version='1.0' encoding='UTF-8'?> -<ns0:Attribute xmlns:ns0="urn:oasis:names:tc:SAML:2.0:assertion" - xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" - FriendlyName="schacHomeOrganization" Name="urn:oid:1.3.6.1.4.1.25178.1.2.9" - NameFormat="urn:oasis:names:tc:SAML:2.0:attrname-format:uri"> - <ns0:AttributeValue xsi:nil="true" xsi:type="xs:string"> - uu.se - </ns0:AttributeValue> -</ns0:Attribute>""" +class BuilderAVA(): + def __init__(self, name, friendly_name, name_format): + template = """<?xml version='1.0' encoding='UTF-8'?> + <ns0:Attribute xmlns:ns0="urn:oasis:names:tc:SAML:2.0:assertion" + xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + Name="{attr_name}" + FriendlyName="{attr_friendly_name}" + NameFormat="{attr_name_format}"> + <ns0:AttributeValue xsi:nil="true" xsi:type="xs:string"> + uu.se + </ns0:AttributeValue> + </ns0:Attribute> + """ + + self.ava = template.format( + attr_name=name, + attr_friendly_name=friendly_name, + attr_name_format=name_format) + + +class TestSchac(): + def test(self): + failures = 0 + friendly_name = "schacHomeOrganization" + ava_schac = BuilderAVA( + "urn:oid:1.3.6.1.4.1.25178.1.2.9", + friendly_name, + saml_map.MAP['identifier']) + + attr = attribute_from_string(ava_schac.ava) + acs = attribute_converter.ac_factory() + + for ac in acs: + try: + res = ac.ava_from(attr) + except KeyError: + failures += 1 + else: + assert res[0] == "schacHomeOrganization" + assert failures != len(acs) -def test_schac(): - attr = attribute_from_string(ava) - acs = attribute_converter.ac_factory() - for ac in acs: - try: - res = ac.ava_from(attr) - assert res[0] == "schacHomeOrganization" - except KeyError: - pass + +class TestEIDAS(): + def test(self): + failures = 0 + friendly_name = 'PersonIdentifier' + ava_eidas = BuilderAVA( + saml_map.EIDAS_NATURALPERSON + friendly_name, + friendly_name, + saml_map.MAP['identifier']) + + attr = attribute_from_string(ava_eidas.ava) + acs = attribute_converter.ac_factory() + + for ac in acs: + try: + res = ac.ava_from(attr) + except KeyError: + failures += 1 + else: + assert res[0] == friendly_name + + assert failures != len(acs) if __name__ == "__main__": diff --git a/tests/test_40_sigver.py b/tests/test_40_sigver.py index 48cf19f2..2511efc4 100644 --- a/tests/test_40_sigver.py +++ b/tests/test_40_sigver.py @@ -18,8 +18,7 @@ from saml2.saml import EncryptedAssertion from saml2.samlp import response_from_string from saml2.s_utils import factory, do_attribute_statement -#from pyasn1.codec.der import decoder - +import pytest from py.test import raises from pathutils import full_path @@ -69,6 +68,10 @@ Yj4cAafWaYfjBU2zi1ElwStIaJ5nyp/s/8B8SAPK2T79McMyccP3wSW13LHkmM1j wKe3ACFXBvqGQN0IbcH49hu0FKhYFM/GPDJcIHFBsiyMBXChpye9vBaTNEBCtU3K jjyG0hRT2mAQ9h+bkPmOvlEo/aH0xR68Z9hw4PF13w==""" +try: + from pyasn1.codec.der import decoder +except ImportError: + decoder = None def test_cert_from_instance_1(): @@ -81,16 +84,18 @@ def test_cert_from_instance_1(): assert certs[0] == CERT1 -# def test_cert_from_instance_ssp(): -# xml_response = open(SIMPLE_SAML_PHP_RESPONSE).read() -# response = samlp.response_from_string(xml_response) -# assertion = response.assertion[0] -# certs = sigver.cert_from_instance(assertion) -# assert len(certs) == 1 -# assert certs[0] == CERT_SSP -# der = base64.b64decode(certs[0]) -# print(str(decoder.decode(der)).replace('.', "\n.")) -# assert decoder.decode(der) +@pytest.mark.skipif(not decoder, + reason="pyasn1 is not installed") +def test_cert_from_instance_ssp(): + xml_response = open(SIMPLE_SAML_PHP_RESPONSE).read() + response = samlp.response_from_string(xml_response) + assertion = response.assertion[0] + certs = sigver.cert_from_instance(assertion) + assert len(certs) == 1 + assert certs[0] == CERT_SSP + der = base64.b64decode(certs[0]) + print(str(decoder.decode(der)).replace('.', "\n.")) + assert decoder.decode(der) class FakeConfig(): diff --git a/tests/test_51_client.py b/tests/test_51_client.py index 8f2e3332..d72d8895 100644 --- a/tests/test_51_client.py +++ b/tests/test_51_client.py @@ -22,6 +22,8 @@ from saml2 import samlp from saml2 import sigver from saml2 import s_utils from saml2.assertion import Assertion +from saml2.extension.requested_attributes import RequestedAttributes +from saml2.extension.requested_attributes import RequestedAttribute from saml2.authn_context import INTERNETPROTOCOLPASSWORD from saml2.client import Saml2Client @@ -280,6 +282,20 @@ class TestClient: assert nid_policy.allow_create == "false" assert nid_policy.format == saml.NAMEID_FORMAT_TRANSIENT + node_requested_attributes = None + for e in ar.extensions.extension_elements: + if e.tag == RequestedAttributes.c_tag: + node_requested_attributes = e + break + assert node_requested_attributes is not None + + for c in node_requested_attributes.children: + assert c.tag == RequestedAttribute.c_tag + assert c.attributes['isRequired'] in ['true', 'false'] + assert c.attributes['Name'] + assert c.attributes['FriendlyName'] + assert c.attributes['NameFormat'] + def test_create_auth_request_unset_force_authn(self): req_id, req = self.client.create_authn_request( "http://www.example.com/sso", sign=False, message_id="id1") diff --git a/tests/test_60_sp.py b/tests/test_60_sp.py index dc72fa2d..78e88400 100644 --- a/tests/test_60_sp.py +++ b/tests/test_60_sp.py @@ -2,12 +2,17 @@ # -*- coding: utf-8 -*- import base64 +import pytest from saml2.authn_context import INTERNETPROTOCOLPASSWORD from saml2.saml import NAMEID_FORMAT_TRANSIENT from saml2.samlp import NameIDPolicy -from saml2.s2repoze.plugins.sp import make_plugin from saml2.server import Server +try: + from saml2.s2repoze.plugins.sp import make_plugin +except ImportError: + make_plugin = None + ENV1 = {'SERVER_SOFTWARE': 'CherryPy/3.1.2 WSGI Server', 'SCRIPT_NAME': '', 'ACTUAL_SERVER_PROTOCOL': 'HTTP/1.1', @@ -43,6 +48,8 @@ AUTHN = { } +@pytest.mark.skipif(not make_plugin, + reason="s2repoze dependencies not installed") class TestSP(): def setup_class(self): self.sp = make_plugin("rem", saml_conf="server_conf") diff --git a/tests/test_83_md_extensions.py b/tests/test_83_md_extensions.py index 71f98868..dace10a5 100644 --- a/tests/test_83_md_extensions.py +++ b/tests/test_83_md_extensions.py @@ -1,5 +1,6 @@ from saml2.config import Config from saml2.metadata import entity_descriptor +from saml2.extension.sp_type import SPType __author__ = 'roland' @@ -14,4 +15,13 @@ assert ed.spsso_descriptor.extensions assert len(ed.spsso_descriptor.extensions.extension_elements) == 3 assert ed.extensions -assert len(ed.extensions.extension_elements) > 1
\ No newline at end of file +assert len(ed.extensions.extension_elements) > 1 + +assert any(e.tag is SPType.c_tag for e in ed.extensions.extension_elements) + +cnf.setattr('sp', 'sp_type_in_metadata', False) +ed = entity_descriptor(cnf) + +print(ed) + +assert all(e.tag is not SPType.c_tag for e in ed.extensions.extension_elements) diff --git a/tools/data/requested_attributes.xsd b/tools/data/requested_attributes.xsd new file mode 100644 index 00000000..b796f3d3 --- /dev/null +++ b/tools/data/requested_attributes.xsd @@ -0,0 +1,28 @@ +<?xml version="1.0" encoding="UTF-8"?> +<xsd:schema + xmlns="http://eidas.europa.eu/saml-extensions" + xmlns:xsd="http://www.w3.org/2001/XMLSchema" + xmlns:saml2="urn:oasis:names:tc:SAML:2.0:assertion" + xmlns:eidas="http://eidas.europa.eu/saml-extensions" + targetNamespace="http://eidas.europa.eu/saml-extensions" + elementFormDefault="qualified" + attributeFormDefault="unqualified" + version="1"> + <xsd:element name="RequestedAttributes" type="eidas:RequestedAttributesType"/> + <xsd:complexType name="RequestedAttributesType"> + <xsd:sequence> + <xsd:element minOccurs="0" maxOccurs="unbounded" ref="eidas:RequestedAttribute"/> + </xsd:sequence> + </xsd:complexType> + <xsd:element name="RequestedAttribute" type="eidas:RequestedAttributeType"/> + <xsd:complexType name="RequestedAttributeType"> + <xsd:sequence> + <xsd:element minOccurs="0" maxOccurs="unbounded" ref="saml2:AttributeValue" type="anyType"/> + </xsd:sequence> + <xsd:attribute name="Name" type="string" use="required"/> + <xsd:attribute name="NameFormat" type="anyURI" use="required"/> + <xsd:attribute name="FriendlyName" type="string" use="optional"/> + <xsd:anyAttribute namespace="##other" processContents="lax"/> + <xsd:attribute name="isRequired" type="boolean" use="optional"/> + </xsd:complexType> +</xsd:schema> diff --git a/tools/data/sp_type.xsd b/tools/data/sp_type.xsd new file mode 100644 index 00000000..dbb1418d --- /dev/null +++ b/tools/data/sp_type.xsd @@ -0,0 +1,16 @@ +<?xml version="1.0" encoding="UTF-8"?> +<xsd:schema + xmlns="http://eidas.europa.eu/saml-extensions" + xmlns:xsd="http://www.w3.org/2001/XMLSchema" + targetNamespace="http://eidas.europa.eu/saml-extensions" + elementFormDefault="qualified" + attributeFormDefault="unqualified" + version="1"> + <xsd:element name="SPType" type="SPTypeType"/> + <xsd:simpleType name="SPTypeType"> + <xsd:restriction base="xsd:string"> + <xsd:enumeration value="public"/> + <xsd:enumeration value="private"/> + </xsd:restriction> + </xsd:simpleType> +</xsd:schema> |