diff --git a/setup.py b/setup.py
index f97f0189..53d8ccd1 100644
--- a/setup.py
+++ b/setup.py
@@ -41,7 +41,8 @@
'isodate>=0.5.0',
'lxml>=3.3.5',
'xmlsec>=1.0.5',
- 'defusedxml==0.6.0'
+ 'defusedxml==0.6.0',
+ 'requests>=2.24.0'
],
dependency_links=['http://github.com/mehcode/python-xmlsec/tarball/master'],
extras_require={
@@ -51,6 +52,7 @@
'pylint==1.9.4',
'flake8==3.6.0',
'coveralls==1.5.1',
+ 'responses>=0.12.0'
),
},
keywords='saml saml2 xmlsec django flask pyramid python3',
diff --git a/src/onelogin/saml2/artifact_resolve.py b/src/onelogin/saml2/artifact_resolve.py
new file mode 100644
index 00000000..9ac0e99b
--- /dev/null
+++ b/src/onelogin/saml2/artifact_resolve.py
@@ -0,0 +1,117 @@
+import logging
+import requests
+
+from base64 import b64decode
+from hashlib import sha1
+
+from onelogin.saml2.utils import OneLogin_Saml2_Utils
+from onelogin.saml2.xml_templates import OneLogin_Saml2_Templates
+from onelogin.saml2.constants import OneLogin_Saml2_Constants
+
+from .errors import OneLogin_Saml2_ValidationError
+
+
+logger = logging.getLogger(__name__)
+
+
+def parse_saml2_artifact(artifact):
+ #
+ # SAMLBind - See 3.6.4 Artifact Format, for SAMLart format.
+ #
+ decoded = b64decode(artifact)
+ type_code = b'\x00\x04'
+
+ if decoded[:2] != type_code:
+ raise OneLogin_Saml2_ValidationError(
+ "The received Artifact does not have the correct header.",
+ OneLogin_Saml2_ValidationError.WRONG_ARTIFACT_FORMAT
+ )
+
+ index = str(int.from_bytes(decoded[2:4], byteorder="big"))
+ sha1_entity_id = decoded[4:24]
+ message_handle = decoded[24:44]
+
+ return index, sha1_entity_id, message_handle
+
+
+class Artifact_Resolve_Request:
+ def __init__(self, settings, saml_art):
+ self.__settings = settings
+ self.soap_endpoint = self.find_soap_endpoint(saml_art)
+ self.saml_art = saml_art
+
+ sp_data = self.__settings.get_sp_data()
+
+ uid = OneLogin_Saml2_Utils.generate_unique_id()
+ self.__id = uid
+
+ issue_instant = OneLogin_Saml2_Utils.parse_time_to_SAML(OneLogin_Saml2_Utils.now())
+
+ request = OneLogin_Saml2_Templates.ARTIFACT_RESOLVE_REQUEST % \
+ {
+ 'id': uid,
+ 'issue_instant': issue_instant,
+ 'entity_id': sp_data['entityId'],
+ 'artifact': saml_art
+ }
+
+ self.__artifact_resolve_request = request
+
+ def find_soap_endpoint(self, saml_art):
+ idp = self.__settings.get_idp_data()
+ index, sha1_entity_id, message_handle = parse_saml2_artifact(saml_art)
+
+ if sha1_entity_id != sha1(idp['entityId'].encode('utf-8')).digest():
+ raise OneLogin_Saml2_ValidationError(
+ f"The sha1 hash of the entityId returned in the SAML Artifact ({sha1_entity_id})"
+ f"does not match the sha1 hash of the configured entityId ({idp['entityId']})"
+ )
+
+ for ars_node in idp['artifactResolutionService']:
+ if ars_node['binding'] != "urn:oasis:names:tc:SAML:2.0:bindings:SOAP":
+ continue
+ if ars_node['index'] == index:
+ return ars_node
+
+ return None
+
+ def get_soap_request(self):
+ request = OneLogin_Saml2_Templates.SOAP_ENVELOPE % \
+ {
+ 'soap_body': self.__artifact_resolve_request
+ }
+
+ return OneLogin_Saml2_Utils.add_sign(
+ request,
+ self.__settings.get_sp_key(), self.__settings.get_sp_cert(),
+ sign_algorithm=OneLogin_Saml2_Constants.RSA_SHA256,
+ digest_algorithm=OneLogin_Saml2_Constants.SHA256,
+ )
+
+ def send(self):
+ security_data = self.__settings.get_security_data()
+ headers = {"content-type": "application/soap+xml"}
+ url = self.soap_endpoint['url']
+ data = self.get_soap_request()
+
+ logger.debug(
+ "Doing a ArtifactResolve (POST) request to %s with data %s",
+ url, data
+ )
+ return requests.post(
+ url=url,
+ cert=(
+ security_data['soapClientCert'],
+ security_data['soapClientKey'],
+ ),
+ data=data,
+ headers=headers,
+ )
+
+ def get_id(self):
+ """
+ Returns the ArtifactResolve ID.
+ :return: ArtifactResolve ID
+ :rtype: string
+ """
+ return self.__id
diff --git a/src/onelogin/saml2/artifact_response.py b/src/onelogin/saml2/artifact_response.py
new file mode 100644
index 00000000..813701a9
--- /dev/null
+++ b/src/onelogin/saml2/artifact_response.py
@@ -0,0 +1,183 @@
+from base64 import b64encode
+from defusedxml.lxml import tostring
+from onelogin.saml2.constants import OneLogin_Saml2_Constants
+from onelogin.saml2.utils import (OneLogin_Saml2_Utils,
+ OneLogin_Saml2_ValidationError)
+from onelogin.saml2.xml_utils import OneLogin_Saml2_XML
+
+
+class Artifact_Response:
+ def __init__(self, settings, response):
+ self.__settings = settings
+ self.__error = None
+ self.id = None
+
+ self.__artifact_response = response
+
+ soap_envelope = OneLogin_Saml2_XML.to_etree(self.__artifact_response)
+
+ self.document = OneLogin_Saml2_XML.query(
+ soap_envelope, '/soap:Envelope/soap:Body'
+ )[0].getchildren()[0]
+
+ self.id = self.document.get('ID', None)
+
+ def get_issuer(self):
+ """
+ Gets the Issuer of the Artifact Response
+ :return: The Issuer
+ :rtype: string
+ """
+ issuer = None
+ issuer_nodes = self.__query('//samlp:ArtifactResponse/saml:Issuer')
+ if len(issuer_nodes) == 1:
+ issuer = OneLogin_Saml2_XML.element_text(issuer_nodes[0])
+ return issuer
+
+ def get_status(self):
+ """
+ Gets the Status
+ :return: The Status
+ :rtype: string
+ """
+ entries = self.__query('//samlp:ArtifactResponse/samlp:Status/samlp:StatusCode')
+ if len(entries) == 0:
+ return None
+ status = entries[0].attrib['Value']
+ return status
+
+ def check_status(self):
+ """
+ Check if the status of the response is success or not
+
+ :raises: Exception. If the status is not success
+ """
+ doc = OneLogin_Saml2_XML.query(self.document, '//samlp:ArtifactResponse')
+ if len(doc) != 1:
+ raise OneLogin_Saml2_ValidationError(
+ 'Missing Status on response',
+ OneLogin_Saml2_ValidationError.MISSING_STATUS
+ )
+ status = OneLogin_Saml2_Utils.get_specific_status(
+ doc[0],
+ )
+ code = status.get('code', None)
+ if code and code != OneLogin_Saml2_Constants.STATUS_SUCCESS:
+ splited_code = code.split(':')
+ printable_code = splited_code.pop()
+ status_exception_msg = 'The status code of the ArtifactResponse was not Success, was %s' % printable_code
+ status_msg = status.get('msg', None)
+ if status_msg:
+ status_exception_msg += ' -> ' + status_msg
+ raise OneLogin_Saml2_ValidationError(
+ status_exception_msg,
+ OneLogin_Saml2_ValidationError.STATUS_CODE_IS_NOT_SUCCESS
+ )
+
+ def is_valid(self, request_id, raise_exceptions=False):
+ """
+ Determines if the SAML ArtifactResponse is valid
+ :param request_id: The ID of the ArtifactResolve sent by this SP to the IdP
+ :type request_id: string
+
+ :param raise_exceptions: Whether to return false on failure or raise an exception
+ :type raise_exceptions: Boolean
+
+ :return: Returns if the SAML ArtifactResponse is or not valid
+ :rtype: boolean
+ """
+ self.__error = None
+ try:
+ idp_data = self.__settings.get_idp_data()
+ idp_entity_id = idp_data['entityId']
+
+ if self.__settings.is_strict():
+ res = OneLogin_Saml2_XML.validate_xml(self.document, 'saml-schema-protocol-2.0.xsd', self.__settings.is_debug_active())
+ if isinstance(res, str):
+ raise OneLogin_Saml2_ValidationError(
+ 'Invalid SAML ArtifactResponse. Not match the saml-schema-protocol-2.0.xsd',
+ OneLogin_Saml2_ValidationError.INVALID_XML_FORMAT
+ )
+
+ security = self.__settings.get_security_data()
+
+ # Check if the InResponseTo of the Artifact Response matches the ID of the Artifact Resolve Request (requestId) if provided
+ in_response_to = self.get_in_response_to()
+ if in_response_to and in_response_to != request_id:
+ raise OneLogin_Saml2_ValidationError(
+ 'The InResponseTo of the Artifact Response: %s, does not match the ID of the Artifact Resolve Request sent by the SP: %s' % (in_response_to, request_id),
+ OneLogin_Saml2_ValidationError.WRONG_INRESPONSETO
+ )
+
+ self.check_status()
+
+ # Check issuer
+ issuer = self.get_issuer()
+ if issuer is not None and issuer != idp_entity_id:
+ raise OneLogin_Saml2_ValidationError(
+ 'Invalid issuer in the Logout Response (expected %(idpEntityId)s, got %(issuer)s)' %
+ {
+ 'idpEntityId': idp_entity_id,
+ 'issuer': issuer
+ },
+ OneLogin_Saml2_ValidationError.WRONG_ISSUER
+ )
+ status = self.get_status()
+ if status != 'urn:oasis:names:tc:SAML:2.0:status:Success':
+ raise OneLogin_Saml2_ValidationError(
+ OneLogin_Saml2_ValidationError.STATUS_CODE_IS_NOT_SUCCESS
+ )
+ return True
+ # pylint: disable=R0801
+ except Exception as err:
+ self.__error = str(err)
+ debug = self.__settings.is_debug_active()
+ if debug:
+ print(err)
+ if raise_exceptions:
+ raise
+ return False
+
+ def __query(self, query):
+ """
+ Extracts a node from the Etree (Logout Response Message)
+ :param query: Xpath Expression
+ :type query: string
+ :return: The queried node
+ :rtype: Element
+ """
+ return OneLogin_Saml2_XML.query(self.document, query)
+
+ def get_in_response_to(self):
+ """
+ Gets the ID of the ArtifactResolve which this response is in response to
+ :returns: ID of ArtifactResolve this LogoutResponse is in response to or None if it is not present
+ :rtype: str
+ """
+ return self.document.get('InResponseTo')
+
+ def get_error(self):
+ """
+ After executing a validation process, if it fails this method returns the cause
+ """
+ return self.__error
+
+ def get_xml(self):
+ """
+ Returns the XML that will be sent as part of the response
+ or that was received at the SP
+ :return: XML response body
+ :rtype: string
+ """
+ return self.__artifact_response
+
+ def get_response_xml(self):
+ """
+ The response is base64 encoded to make it possible to feed
+ it to the OneLogin_Saml2_Response class.
+ """
+ return b64encode(
+ tostring(
+ self.__query('//samlp:ArtifactResponse/samlp:Response')[0]
+ )
+ )
diff --git a/src/onelogin/saml2/auth.py b/src/onelogin/saml2/auth.py
index a67e2071..d44f4d78 100644
--- a/src/onelogin/saml2/auth.py
+++ b/src/onelogin/saml2/auth.py
@@ -11,10 +11,14 @@
"""
+import logging
import xmlsec
+
from onelogin.saml2 import compat
from onelogin.saml2.authn_request import OneLogin_Saml2_Authn_Request
+from onelogin.saml2.artifact_resolve import Artifact_Resolve_Request
+from onelogin.saml2.artifact_response import Artifact_Response
from onelogin.saml2.constants import OneLogin_Saml2_Constants
from onelogin.saml2.logout_request import OneLogin_Saml2_Logout_Request
from onelogin.saml2.logout_response import OneLogin_Saml2_Logout_Response
@@ -24,6 +28,9 @@
from onelogin.saml2.xmlparser import tostring
+logger = logging.getLogger(__name__)
+
+
class OneLogin_Saml2_Auth(object):
"""
@@ -94,6 +101,62 @@ def set_strict(self, value):
assert isinstance(value, bool)
self.__settings.set_strict(value)
+ def artifact_resolve(self, saml_art):
+ """
+ Try to resolve the given artifact
+ """
+ logger.debug(
+ "Retrieved the SAMLArt %s via the ACS.", saml_art
+ )
+
+ resolve_request = Artifact_Resolve_Request(self.__settings, saml_art)
+ resolve_response = resolve_request.send()
+ if resolve_response.status_code != 200:
+ raise OneLogin_Saml2_ValidationError(
+ "Received a status code {status_code} when trying to resolve the given artifact {saml_art}".format(
+ status_code=resolve_response.status_code, saml_art=saml_art
+ )
+ )
+
+ logger.debug(
+ "Retrieved a ArtifactResponse with content %s", resolve_response.content
+ )
+
+ artifact_response = Artifact_Response(self.__settings, resolve_response.content)
+ if not artifact_response.is_valid(resolve_request.get_id()):
+ raise OneLogin_Saml2_ValidationError(
+ "The ArtifactResponse could not be validated due to the following error: {error}".format(
+ error=artifact_response.get_error()
+ )
+ )
+
+ saml2_response = OneLogin_Saml2_Response(self.__settings, artifact_response.get_response_xml())
+ try:
+ saml2_response.is_valid(self.__request_data, raise_exceptions=True)
+ except OneLogin_Saml2_ValidationError as e:
+ raise OneLogin_Saml2_ValidationError(
+ "The Response could not be validated due to the following error: {error}".format(
+ error=saml2_response.get_error()
+ ), code=e.code
+ )
+
+ return saml2_response
+
+ def store_valid_response(self, response):
+ self.__attributes = response.get_attributes()
+ self.__friendlyname_attributes = response.get_friendlyname_attributes()
+ self.__nameid = response.get_nameid()
+ self.__nameid_format = response.get_nameid_format()
+ self.__nameid_nq = response.get_nameid_nq()
+ self.__nameid_spnq = response.get_nameid_spnq()
+ self.__session_index = response.get_session_index()
+ self.__session_expiration = response.get_session_not_on_or_after()
+ self.__last_message_id = response.get_id()
+ self.__last_assertion_id = response.get_assertion_id()
+ self.__last_authn_contexts = response.get_authn_contexts()
+ self.__authenticated = True
+ self.__last_assertion_not_on_or_after = response.get_assertion_not_on_or_after()
+
def process_response(self, request_id=None):
"""
Process the SAML Response sent by the IdP.
@@ -112,24 +175,18 @@ def process_response(self, request_id=None):
self.__last_response = response.get_xml_document()
if response.is_valid(self.__request_data, request_id):
- self.__attributes = response.get_attributes()
- self.__friendlyname_attributes = response.get_friendlyname_attributes()
- self.__nameid = response.get_nameid()
- self.__nameid_format = response.get_nameid_format()
- self.__nameid_nq = response.get_nameid_nq()
- self.__nameid_spnq = response.get_nameid_spnq()
- self.__session_index = response.get_session_index()
- self.__session_expiration = response.get_session_not_on_or_after()
- self.__last_message_id = response.get_id()
- self.__last_assertion_id = response.get_assertion_id()
- self.__last_authn_contexts = response.get_authn_contexts()
- self.__authenticated = True
- self.__last_assertion_not_on_or_after = response.get_assertion_not_on_or_after()
-
+ self.store_valid_response(response)
else:
self.__errors.append('invalid_response')
self.__error_reason = response.get_error()
-
+ elif 'get_data' in self.__request_data and 'SAMLArt' in self.__request_data['get_data']:
+ try:
+ response = self.artifact_resolve(self.__request_data['get_data']['SAMLArt'])
+ except OneLogin_Saml2_ValidationError as e:
+ self.__errors.append('invalid_response')
+ self.__error_reason = str(e)
+ else:
+ self.store_valid_response(response)
else:
self.__errors.append('invalid_binding')
raise OneLogin_Saml2_Error(
diff --git a/src/onelogin/saml2/authn_request.py b/src/onelogin/saml2/authn_request.py
index 48ad9d2a..c0ced249 100644
--- a/src/onelogin/saml2/authn_request.py
+++ b/src/onelogin/saml2/authn_request.py
@@ -124,6 +124,7 @@ def __init__(self, settings, force_authn=False, is_passive=False, set_nameid_pol
'nameid_policy_str': nameid_policy_str,
'requested_authn_context_str': requested_authn_context_str,
'attr_consuming_service_str': attr_consuming_service_str,
+ 'acs_binding': sp_data['assertionConsumerService'].get('binding', 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST')
}
self.__authn_request = request
diff --git a/src/onelogin/saml2/constants.py b/src/onelogin/saml2/constants.py
index e85a7fb9..cee93a83 100644
--- a/src/onelogin/saml2/constants.py
+++ b/src/onelogin/saml2/constants.py
@@ -56,6 +56,7 @@ class OneLogin_Saml2_Constants(object):
NS_PREFIX_XSD = 'xsd'
NS_PREFIX_XENC = 'xenc'
NS_PREFIX_DS = 'ds'
+ NS_PREFIX_SOAP = 'soap'
# Prefix:Namespace Mappings
NSMAP = {
@@ -63,7 +64,8 @@ class OneLogin_Saml2_Constants(object):
NS_PREFIX_SAML: NS_SAML,
NS_PREFIX_DS: NS_DS,
NS_PREFIX_XENC: NS_XENC,
- NS_PREFIX_MD: NS_MD
+ NS_PREFIX_MD: NS_MD,
+ NS_PREFIX_SOAP: NS_SOAP
}
# Bindings
@@ -94,6 +96,7 @@ class OneLogin_Saml2_Constants(object):
STATUS_NO_PASSIVE = 'urn:oasis:names:tc:SAML:2.0:status:NoPassive'
STATUS_PARTIAL_LOGOUT = 'urn:oasis:names:tc:SAML:2.0:status:PartialLogout'
STATUS_PROXY_COUNT_EXCEEDED = 'urn:oasis:names:tc:SAML:2.0:status:ProxyCountExceeded'
+ STATUS_AUTHN_FAILED = 'urn:oasis:names:tc:SAML:2.0:status:AuthnFailed'
# Sign & Crypto
SHA1 = 'http://www.w3.org/2000/09/xmldsig#sha1'
diff --git a/src/onelogin/saml2/errors.py b/src/onelogin/saml2/errors.py
index 6a50f9f1..5c95633e 100644
--- a/src/onelogin/saml2/errors.py
+++ b/src/onelogin/saml2/errors.py
@@ -110,6 +110,7 @@ class OneLogin_Saml2_ValidationError(Exception):
WRONG_NUMBER_OF_SIGNATURES = 43
RESPONSE_EXPIRED = 44
AUTHN_CONTEXT_MISMATCH = 45
+ STATUS_CODE_AUTHNFAILED = 46
def __init__(self, message, code=0, errors=None):
"""
@@ -119,7 +120,6 @@ def __init__(self, message, code=0, errors=None):
* (int) code. The code error (defined in the error class).
"""
assert isinstance(code, int)
-
if errors is not None:
message = message % errors
diff --git a/src/onelogin/saml2/idp_metadata_parser.py b/src/onelogin/saml2/idp_metadata_parser.py
index 2d7eec2c..ae2273a3 100644
--- a/src/onelogin/saml2/idp_metadata_parser.py
+++ b/src/onelogin/saml2/idp_metadata_parser.py
@@ -126,7 +126,7 @@ def parse(
data = {}
dom = OneLogin_Saml2_XML.to_etree(idp_metadata)
- idp_entity_id = want_authn_requests_signed = idp_name_id_format = idp_sso_url = idp_slo_url = certs = None
+ idp_entity_id = want_authn_requests_signed = idp_name_id_format = idp_sso_url = idp_slo_url = idp_ars_url = idp_ars_index = certs = None
entity_desc_path = '//md:EntityDescriptor'
if entity_id:
@@ -163,6 +163,13 @@ def parse(
if len(slo_nodes) > 0:
idp_slo_url = slo_nodes[0].get('Location', None)
+ ars_nodes = OneLogin_Saml2_XML.query(
+ idp_descriptor_node,
+ "./md:ArtifactResolutionService[@Binding='urn:oasis:names:tc:SAML:2.0:bindings:SOAP']"
+ )
+
+
+
signing_nodes = OneLogin_Saml2_XML.query(idp_descriptor_node, "./md:KeyDescriptor[not(contains(@use, 'encryption'))]/ds:KeyInfo/ds:X509Data/ds:X509Certificate")
encryption_nodes = OneLogin_Saml2_XML.query(idp_descriptor_node, "./md:KeyDescriptor[not(contains(@use, 'signing'))]/ds:KeyInfo/ds:X509Data/ds:X509Certificate")
@@ -192,6 +199,17 @@ def parse(
data['idp']['singleLogoutService']['url'] = idp_slo_url
data['idp']['singleLogoutService']['binding'] = required_slo_binding
+ for ars_node in ars_nodes:
+ idp_ars_url = ars_node.get('Location', None)
+ idp_ars_index = ars_node.get('index', None)
+ if idp_ars_url is not None:
+ ars_list = data['idp'].setdefault('artifactResolutionService', [])
+ ars_list.append({
+ 'url': idp_ars_url,
+ 'index': idp_ars_index,
+ 'binding': "urn:oasis:names:tc:SAML:2.0:bindings:SOAP",
+ })
+
if want_authn_requests_signed is not None:
data['security'] = {}
data['security']['authnRequestsSigned'] = want_authn_requests_signed
diff --git a/src/onelogin/saml2/response.py b/src/onelogin/saml2/response.py
index 04264919..4fd873a4 100644
--- a/src/onelogin/saml2/response.py
+++ b/src/onelogin/saml2/response.py
@@ -334,17 +334,24 @@ def check_status(self):
:raises: Exception. If the status is not success
"""
status = OneLogin_Saml2_Utils.get_status(self.document)
- code = status.get('code', None)
- if code and code != OneLogin_Saml2_Constants.STATUS_SUCCESS:
- splited_code = code.split(':')
+ codes = status.get('codes', None)
+ if codes and codes[0] != OneLogin_Saml2_Constants.STATUS_SUCCESS:
+ splited_code = codes[0].split(':')
printable_code = splited_code.pop()
status_exception_msg = 'The status code of the Response was not Success, was %s' % printable_code
status_msg = status.get('msg', None)
if status_msg:
status_exception_msg += ' -> ' + status_msg
+
+ exception_code = OneLogin_Saml2_ValidationError.STATUS_CODE_IS_NOT_SUCCESS
+
+ # See SAMLCore - 3.2.2.2. AuthnFailed is a second-level status code.
+ if len(codes) >= 2 and codes[1] == OneLogin_Saml2_Constants.STATUS_AUTHN_FAILED:
+ exception_code = OneLogin_Saml2_ValidationError.STATUS_CODE_AUTHNFAILED
+
raise OneLogin_Saml2_ValidationError(
status_exception_msg,
- OneLogin_Saml2_ValidationError.STATUS_CODE_IS_NOT_SUCCESS
+ exception_code
)
def check_one_condition(self):
@@ -666,8 +673,7 @@ def process_signed_elements(self):
:returns: The signed elements tag names
:rtype: list
"""
- sign_nodes = self.__query('//ds:Signature')
-
+ sign_nodes = self.__query('//ds:Signature[not(ancestor::saml:Advice)]')
signed_elements = []
verified_seis = []
verified_ids = []
diff --git a/src/onelogin/saml2/utils.py b/src/onelogin/saml2/utils.py
index 1290033e..1eb8b923 100644
--- a/src/onelogin/saml2/utils.py
+++ b/src/onelogin/saml2/utils.py
@@ -632,39 +632,52 @@ def generate_name_id(value, sp_nq, sp_format=None, cert=None, debug=False, nq=No
else:
return OneLogin_Saml2_XML.extract_tag_text(root, "saml:NameID")
- @staticmethod
- def get_status(dom):
+ @classmethod
+ def get_status(cls, dom):
"""
Gets Status from a Response.
:param dom: The Response as XML
:type: Document
- :returns: The Status, an array with the code and a message.
+ :returns: The Status, an array with the code and a message. 'code' entry is the
+ topmost StatusCode, and 'codes' entry contains the StatusCodes in document
+ order.
:rtype: dict
"""
+ doc = OneLogin_Saml2_XML.query(dom, '/samlp:Response')
+ if len(doc) != 1:
+ raise OneLogin_Saml2_ValidationError(
+ 'Missing Status on response',
+ OneLogin_Saml2_ValidationError.MISSING_STATUS
+ )
+
+ return cls.get_specific_status(doc[0])
+
+ @staticmethod
+ def get_specific_status(doc):
status = {}
- status_entry = OneLogin_Saml2_XML.query(dom, '/samlp:Response/samlp:Status')
+ status_entry = OneLogin_Saml2_XML.query(doc, './samlp:Status')
if len(status_entry) != 1:
raise OneLogin_Saml2_ValidationError(
'Missing Status on response',
OneLogin_Saml2_ValidationError.MISSING_STATUS
)
- code_entry = OneLogin_Saml2_XML.query(dom, '/samlp:Response/samlp:Status/samlp:StatusCode', status_entry[0])
- if len(code_entry) != 1:
+ code_entries = OneLogin_Saml2_XML.query(doc, './/samlp:StatusCode', status_entry[0])
+ if not code_entries:
raise OneLogin_Saml2_ValidationError(
'Missing Status Code on response',
OneLogin_Saml2_ValidationError.MISSING_STATUS_CODE
)
- code = code_entry[0].values()[0]
- status['code'] = code
+ status['codes'] = [c.get('Value') for c in code_entries]
+ status['code'] = status['codes'][0]
status['msg'] = ''
- message_entry = OneLogin_Saml2_XML.query(dom, '/samlp:Response/samlp:Status/samlp:StatusMessage', status_entry[0])
+ message_entry = OneLogin_Saml2_XML.query(doc, './samlp:StatusMessage', status_entry[0])
if len(message_entry) == 0:
- subcode_entry = OneLogin_Saml2_XML.query(dom, '/samlp:Response/samlp:Status/samlp:StatusCode/samlp:StatusCode', status_entry[0])
+ subcode_entry = OneLogin_Saml2_XML.query(doc, './samlp:StatusCode/samlp:StatusCode', status_entry[0])
if len(subcode_entry) == 1:
status['msg'] = subcode_entry[0].values()[0]
elif len(message_entry) == 1:
diff --git a/src/onelogin/saml2/xml_templates.py b/src/onelogin/saml2/xml_templates.py
index 306b1afe..4442dd51 100644
--- a/src/onelogin/saml2/xml_templates.py
+++ b/src/onelogin/saml2/xml_templates.py
@@ -27,12 +27,31 @@ class OneLogin_Saml2_Templates(object):
Version="2.0"%(provider_name)s%(force_authn_str)s%(is_passive_str)s
IssueInstant="%(issue_instant)s"
Destination="%(destination)s"
- ProtocolBinding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST"
+ ProtocolBinding="%(acs_binding)s"
AssertionConsumerServiceURL="%(assertion_url)s"%(attr_consuming_service_str)s>
%(entity_id)s%(subject_str)s%(nameid_policy_str)s
%(requested_authn_context_str)s
"""
+ SOAP_ENVELOPE = """\
+
+
+%(soap_body)s
+
+
+ """
+
+ ARTIFACT_RESOLVE_REQUEST = """\
+
+ %(entity_id)s
+ %(artifact)s
+
+ """
+
LOGOUT_REQUEST = """\
+
+
+ https://idp.com/saml/idp/metadata
+
+
+
+
+ https://idp.com/saml/idp/metadata
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ https://idp.com/saml/idp/metadata
+
+ s00000000:12345678
+
+
+
+
+
+
+ sp.com
+
+
+
+
+
+ urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport
+
+
+
+
+
+
+
diff --git a/tests/data/artifact_response/artifact_response_invalid.xml b/tests/data/artifact_response/artifact_response_invalid.xml
new file mode 100644
index 00000000..c09a587b
--- /dev/null
+++ b/tests/data/artifact_response/artifact_response_invalid.xml
@@ -0,0 +1,56 @@
+
+
+
+ https://idp.com/saml/idp/metadata
+
+
+
+
+ https://idp.com/saml/idp/metadata
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ https://idp.com/saml/idp/metadata
+
+ s00000000:12345678
+
+
+
+
+
+
+ sp.com
+
+
+
+
+
+ urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport
+
+
+
+
+
+
+
diff --git a/tests/settings/settings11.json b/tests/settings/settings11.json
new file mode 100644
index 00000000..71a1ddd9
--- /dev/null
+++ b/tests/settings/settings11.json
@@ -0,0 +1,33 @@
+{
+ "strict": false,
+ "debug": false,
+ "custom_base_path": "../../../tests/data/customPath/",
+ "sp": {
+ "entityId": "http://stuff.com/endpoints/metadata.php",
+ "assertionConsumerService": {
+ "url": "http://stuff.com/endpoints/endpoints/acs.php",
+ "binding": "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Artifact"
+ },
+ "NameIDFormat": "urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified"
+ },
+ "idp": {
+ "entityId": "https://idp.com/saml/idp/metadata",
+ "singleSignOnService": {
+ "url": "https://idp.com/saml/idp/request_authentication",
+ "binding": "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST"
+ },
+ "artifactResolutionService": [{
+ "index": "0",
+ "url": "https://idp.com/saml/idp/resolve_artifact",
+ "binding": "urn:oasis:names:tc:SAML:2.0:bindings:SOAP"
+ }],
+ "x509cert": "MIICgTCCAeoCCQCbOlrWDdX7FTANBgkqhkiG9w0BAQUFADCBhDELMAkGA1UEBhMCTk8xGDAWBgNVBAgTD0FuZHJlYXMgU29sYmVyZzEMMAoGA1UEBxMDRm9vMRAwDgYDVQQKEwdVTklORVRUMRgwFgYDVQQDEw9mZWlkZS5lcmxhbmcubm8xITAfBgkqhkiG9w0BCQEWEmFuZHJlYXNAdW5pbmV0dC5ubzAeFw0wNzA2MTUxMjAxMzVaFw0wNzA4MTQxMjAxMzVaMIGEMQswCQYDVQQGEwJOTzEYMBYGA1UECBMPQW5kcmVhcyBTb2xiZXJnMQwwCgYDVQQHEwNGb28xEDAOBgNVBAoTB1VOSU5FVFQxGDAWBgNVBAMTD2ZlaWRlLmVybGFuZy5ubzEhMB8GCSqGSIb3DQEJARYSYW5kcmVhc0B1bmluZXR0Lm5vMIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDivbhR7P516x/S3BqKxupQe0LONoliupiBOesCO3SHbDrl3+q9IbfnfmE04rNuMcPsIxB161TdDpIesLCn7c8aPHISKOtPlAeTZSnb8QAu7aRjZq3+PbrP5uW3TcfCGPtKTytHOge/OlJbo078dVhXQ14d1EDwXJW1rRXuUt4C8QIDAQABMA0GCSqGSIb3DQEBBQUAA4GBACDVfp86HObqY+e8BUoWQ9+VMQx1ASDohBjwOsg2WykUqRXF+dLfcUH9dWR63CtZIKFDbStNomPnQz7nbK+onygwBspVEbnHuUihZq3ZUdmumQqCw4Uvs/1Uvq3orOo/WJVhTyvLgFVK2QarQ4/67OZfHd7R+POBXhophSMv1ZOo"
+ },
+ "security": {
+ "soapClientCert": "abc",
+ "soapClientKey": "abc",
+ "authnRequestsSigned": false,
+ "wantAssertionsSigned": false,
+ "signMetadata": false
+ }
+}
diff --git a/tests/src/OneLogin/saml2_tests/artifact_response_test.py b/tests/src/OneLogin/saml2_tests/artifact_response_test.py
new file mode 100644
index 00000000..4be62cd5
--- /dev/null
+++ b/tests/src/OneLogin/saml2_tests/artifact_response_test.py
@@ -0,0 +1,104 @@
+from base64 import b64decode, b64encode
+from lxml import etree
+from datetime import datetime
+from datetime import timedelta
+from freezegun import freeze_time
+import json
+from os.path import dirname, join, exists
+import unittest
+from xml.dom.minidom import parseString
+
+from onelogin.saml2 import compat
+from onelogin.saml2.artifact_response import Artifact_Response
+from onelogin.saml2.settings import OneLogin_Saml2_Settings
+from onelogin.saml2.utils import OneLogin_Saml2_Utils, OneLogin_Saml2_ValidationError
+from onelogin.saml2.xml_utils import OneLogin_Saml2_XML
+
+class Saml2_Artifact_Response_Test(unittest.TestCase):
+ data_path = join(dirname(dirname(dirname(dirname(__file__)))), 'data')
+ settings_path = join(dirname(dirname(dirname(dirname(__file__)))), 'settings')
+
+ def loadSettingsJSON(self, name='settings1.json'):
+ filename = join(self.settings_path, name)
+ if exists(filename):
+ stream = open(filename, 'r')
+ settings = json.load(stream)
+ stream.close()
+ return settings
+
+ def file_contents(self, filename):
+ f = open(filename, 'r')
+ content = f.read()
+ f.close()
+ return content
+
+ def testConstruct(self):
+ response = self.file_contents(join(
+ self.data_path, 'artifact_response', 'artifact_response.xml'
+ ))
+ settings = OneLogin_Saml2_Settings(self.loadSettingsJSON())
+ response_enc = Artifact_Response(settings, response)
+
+ self.assertIsInstance(response_enc, Artifact_Response)
+
+ def testGetResponseXml(self):
+ response_data = self.file_contents(join(
+ self.data_path, 'artifact_response', 'artifact_response.xml'
+ ))
+ response_etree = OneLogin_Saml2_XML.to_etree(response_data)
+ samlp_response = OneLogin_Saml2_XML.query(response_etree, '//samlp:Response')[0]
+ expected_data = b64encode(OneLogin_Saml2_XML.to_string(samlp_response))
+
+ settings = OneLogin_Saml2_Settings(self.loadSettingsJSON())
+ response = Artifact_Response(settings, response_data)
+
+ self.assertEqual(
+ response.get_response_xml(),
+ expected_data
+ )
+
+ def testIsValid(self):
+ response = self.file_contents(join(
+ self.data_path, 'artifact_response', 'artifact_response.xml'
+ ))
+ json_settings = self.loadSettingsJSON(name='settings11.json')
+ json_settings['strict'] = True
+ settings = OneLogin_Saml2_Settings(json_settings)
+ response = Artifact_Response(settings, response)
+
+ self.assertTrue(response.is_valid(
+ 'ONELOGIN_5ba93c9db0cff93f52b521d7420e43f6eda2784f'
+ ))
+
+ def testGetIssuer(self):
+ response = self.file_contents(join(
+ self.data_path, 'artifact_response', 'artifact_response.xml'
+ ))
+ settings = OneLogin_Saml2_Settings(self.loadSettingsJSON())
+ response = Artifact_Response(settings, response)
+
+ self.assertEqual(response.get_issuer(), 'https://idp.com/saml/idp/metadata')
+
+ def testGetStatus(self):
+ response = self.file_contents(join(
+ self.data_path, 'artifact_response', 'artifact_response.xml'
+ ))
+ settings = OneLogin_Saml2_Settings(self.loadSettingsJSON())
+ response = Artifact_Response(settings, response)
+
+ self.assertEqual(response.get_status(), 'urn:oasis:names:tc:SAML:2.0:status:Success')
+
+ def testCheckStatus(self):
+ response = self.file_contents(join(
+ self.data_path, 'artifact_response', 'artifact_response_invalid.xml'
+ ))
+ settings = OneLogin_Saml2_Settings(self.loadSettingsJSON())
+ response = Artifact_Response(settings, response)
+
+ with self.assertRaises(OneLogin_Saml2_ValidationError) as context:
+ response.check_status()
+
+ self.assertEqual(
+ str(context.exception),
+ 'The status code of the ArtifactResponse was not Success, was Responder'
+ )
\ No newline at end of file
diff --git a/tests/src/OneLogin/saml2_tests/auth_test.py b/tests/src/OneLogin/saml2_tests/auth_test.py
index ea8cf1d3..2d6b0cda 100644
--- a/tests/src/OneLogin/saml2_tests/auth_test.py
+++ b/tests/src/OneLogin/saml2_tests/auth_test.py
@@ -4,10 +4,13 @@
# MIT License
from base64 import b64decode, b64encode
+from hashlib import sha1
import json
from os.path import dirname, join, exists
import unittest
+import responses
+
from onelogin.saml2 import compat
from onelogin.saml2.auth import OneLogin_Saml2_Auth
from onelogin.saml2.constants import OneLogin_Saml2_Constants
@@ -21,6 +24,14 @@
from urlparse import urlparse, parse_qs
+def create_example_artifact(endpoint_url, endpoint_index=b"\x00\x00"):
+ type_code = b"\x00\x04"
+ source_id = sha1(endpoint_url.encode("utf-8")).digest()
+ message_handle = b"01234567890123456789" # something random
+
+ return b64encode(type_code + endpoint_index + source_id + message_handle)
+
+
class OneLogin_Saml2_Auth_Test(unittest.TestCase):
data_path = join(dirname(dirname(dirname(dirname(__file__)))), 'data')
settings_path = join(dirname(dirname(dirname(dirname(__file__)))), 'settings')
@@ -1478,3 +1489,116 @@ def testGetIdFromLogoutResponse(self):
auth = OneLogin_Saml2_Auth(message_wrapper, old_settings=settings)
auth.process_slo()
self.assertIn(auth.get_last_message_id(), '_f9ee61bd9dbf63606faa9ae3b10548d5b3656fb859')
+
+ @unittest.mock.patch('onelogin.saml2.utils.OneLogin_Saml2_Utils.validate_sign')
+ @responses.activate
+ def testArtifactResponseSoapRequest(self, mock):
+ """
+ Test that a Artifact Response, makes a SOAP request using the received
+ saml artifact and returns the response.
+ """
+ mock.return_value = True
+
+ saml_art = create_example_artifact(
+ "https://idp.com/saml/idp/metadata"
+ ).decode('utf-8')
+ response = self.file_contents(join(
+ self.data_path, 'artifact_response', 'artifact_response.xml'
+ ))
+ responses.add(
+ responses.POST,
+ "https://idp.com/saml/idp/resolve_artifact",
+ body=response,
+ status=200,
+ )
+
+ settings_info = self.loadSettingsJSON(name='settings11.json')
+ request_data = self.get_request()
+ request_data['get_data'] = {
+ 'SAMLArt': saml_art
+ }
+ auth = OneLogin_Saml2_Auth(request_data, old_settings=settings_info)
+ auth.process_response()
+
+ self.assertEqual(
+ responses.calls[0].request.url,
+ 'https://idp.com/saml/idp/resolve_artifact'
+ )
+ self.assertEqual(
+ responses.calls[0].request.method,
+ 'POST'
+ )
+
+ self.assertIn(
+ f'{saml_art}',
+ responses.calls[0].request.body.decode('utf-8')
+ )
+
+ @unittest.mock.patch('onelogin.saml2.utils.OneLogin_Saml2_Utils.validate_sign')
+ @responses.activate
+ def testArtifactGetInfoFromLastResponseReceived(self, mock):
+ mock.return_value = True
+
+ saml_art = create_example_artifact(
+ "https://idp.com/saml/idp/metadata"
+ ).decode('utf-8')
+ response = self.file_contents(join(
+ self.data_path, 'artifact_response', 'artifact_response.xml'
+ ))
+ responses.add(
+ responses.POST,
+ "https://idp.com/saml/idp/resolve_artifact",
+ body=response,
+ status=200,
+ )
+
+ settings_info = self.loadSettingsJSON(name='settings11.json')
+ request_data = self.get_request()
+ request_data['get_data'] = {
+ 'SAMLArt': saml_art
+ }
+ auth = OneLogin_Saml2_Auth(request_data, old_settings=settings_info)
+ auth.process_response()
+
+ self.assertEqual(len(auth.get_errors()), 0)
+ self.assertEqual(auth.get_last_message_id(), '_1072ee96')
+ self.assertEqual(auth.get_last_assertion_id(), '_dc9f70e61c')
+ self.assertEqual(auth.get_last_assertion_not_on_or_after(), None)
+
+ @unittest.mock.patch('onelogin.saml2.utils.OneLogin_Saml2_Utils.validate_sign')
+ @responses.activate
+ def testArtifactErrorCase(self, mock):
+ mock.return_value = True
+
+ saml_art = create_example_artifact(
+ "https://idp.com/saml/idp/metadata"
+ ).decode('utf-8')
+ response = self.file_contents(join(
+ self.data_path, 'artifact_response', 'artifact_response_invalid.xml'
+ ))
+ responses.add(
+ responses.POST,
+ "https://idp.com/saml/idp/resolve_artifact",
+ body=response,
+ status=200,
+ )
+
+ settings_info = self.loadSettingsJSON(name='settings11.json')
+ request_data = self.get_request()
+ request_data['get_data'] = {
+ 'SAMLArt': saml_art
+ }
+ auth = OneLogin_Saml2_Auth(request_data, old_settings=settings_info)
+ auth.set_strict(True)
+ auth.process_response()
+
+ self.assertIn(
+ 'The ArtifactResponse could not be validated due to the following error: '
+ 'The InResponseTo of the Artifact Response: ',
+ auth.get_last_error_reason(),
+ )
+
+ self.assertEqual(len(auth.get_errors()), 1)
+ self.assertIsNone(auth.get_last_message_id())
+ self.assertIsNone(auth.get_last_assertion_id())
+ self.assertIsNone(auth.get_last_assertion_not_on_or_after())
diff --git a/tests/src/OneLogin/saml2_tests/idp_metadata_parser_test.py b/tests/src/OneLogin/saml2_tests/idp_metadata_parser_test.py
index 4aa653b5..13451a32 100644
--- a/tests/src/OneLogin/saml2_tests/idp_metadata_parser_test.py
+++ b/tests/src/OneLogin/saml2_tests/idp_metadata_parser_test.py
@@ -81,7 +81,12 @@ def testParseRemote(self):
"singleSignOnService": {
"url": "https://idp.testshib.org/idp/profile/SAML2/Redirect/SSO",
"binding": "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect"
- }
+ },
+ "artifactResolutionService": [{
+ "url": "https://idp.testshib.org:8443/idp/profile/SAML2/SOAP/ArtifactResolution",
+ "index": "2",
+ "binding": "urn:oasis:names:tc:SAML:2.0:bindings:SOAP"
+ }]
}
}
"""
@@ -142,7 +147,12 @@ def test_parse_testshib_required_binding_sso_redirect(self):
"singleSignOnService": {
"url": "https://idp.testshib.org/idp/profile/SAML2/Redirect/SSO",
"binding": "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect"
- }
+ },
+ "artifactResolutionService": [{
+ "url": "https://idp.testshib.org:8443/idp/profile/SAML2/SOAP/ArtifactResolution",
+ "index": "2",
+ "binding": "urn:oasis:names:tc:SAML:2.0:bindings:SOAP"
+ }]
}
}
"""
@@ -180,7 +190,12 @@ def test_parse_testshib_required_binding_sso_post(self):
"singleSignOnService": {
"url": "https://idp.testshib.org/idp/profile/SAML2/POST/SSO",
"binding": "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST"
- }
+ },
+ "artifactResolutionService": [{
+ "url": "https://idp.testshib.org:8443/idp/profile/SAML2/SOAP/ArtifactResolution",
+ "index": "2",
+ "binding": "urn:oasis:names:tc:SAML:2.0:bindings:SOAP"
+ }]
}
}
"""