diff --git a/.travis.yml b/.travis.yml
index f7aca6f7e..d9380b2ce 100644
--- a/.travis.yml
+++ b/.travis.yml
@@ -17,6 +17,9 @@ matrix:
env:
- REQUIREMENTS=requirements-python3.txt
- TEST_REQUIREMENTS=social/tests/requirements-python3.txt
+before_install:
+ - sudo apt-get update -qq
+ - sudo apt-get install -y libxmlsec1-dev swig
install:
- "python setup.py -q install"
- "travis_retry pip install -r $REQUIREMENTS"
diff --git a/docs/backends/saml.rst b/docs/backends/saml.rst
new file mode 100644
index 000000000..72de2fd0d
--- /dev/null
+++ b/docs/backends/saml.rst
@@ -0,0 +1,171 @@
+SAML
+====
+
+The SAML backend allows users to authenticate with any provider that supports
+the SAML 2.0 protocol (commonly used for corporate or academic single sign on).
+
+The SAML backend for python-social-auth allows your web app to act as a SAML
+Service Provider. You can configure one or more SAML Identity Providers that
+users can use for authentication. For example, if your users are students, you
+could enable Harvard and MIT as identity providers, so that students of either
+of those two universities can use their campus login to access your app.
+
+Required Configuration
+----------------------
+
+At a minimum, you must add the following to your project's settings:
+
+- ``SOCIAL_AUTH_SAML_SP_ENTITY_ID``: The SAML Entity ID for your app. This
+ should be a URL that includes a domain name you own. It doesn't matter what
+ the URL points to. Example: ``http://saml.yoursite.com``
+
+- ``SOCIAL_AUTH_SAML_SP_PUBLIC_CERT``: The X.509 certificate string for the
+ key pair that your app will use. You can generate a new self-signed key pair
+ with::
+
+ openssl req -new -x509 -days 3652 -nodes -out saml.crt -keyout saml.key
+
+ The contents of ``saml.crt`` should then be used as the value of this setting
+ (you can omit the first and last lines, which aren't required).
+
+- ``SOCIAL_AUTH_SAML_SP_PRIVATE_KEY``: The private key to be used by your app.
+ If you used the example openssl command given above, set this to the contents
+ of ``saml.key`` (again, you can omit the first and last lines).
+
+- ``SOCIAL_AUTH_SAML_ORG_INFO``: A dictionary that contains information about
+ your app. You must specify values for English at a minimum. Each language's
+ entry should specify a ``name`` (not shown to the user), a ``displayname``
+ (shown to the user), and a URL. See the following
+ example::
+
+ {
+ "en-US": {
+ "name": "example",
+ "displayname": "Example Inc.",
+ "url": "http://example.com",
+ }
+ }
+
+- ``SOCIAL_AUTH_SAML_TECHNICAL_CONTACT``: A dictionary with two values,
+ ``givenName`` and ``emailAddress``, describing the name and email of a
+ technical contact responsible for your app. Example::
+
+ {"givenName": "Tech Gal", "emailAddress": "technical@example.com"}
+
+- ``SOCIAL_AUTH_SAML_TECHNICAL_CONTACT``: A dictionary with two values,
+ ``givenName`` and ``emailAddress``, describing the name and email of a
+ support contact for your app. Example::
+
+ SOCIAL_AUTH_SAML_SUPPORT_CONTACT = {
+ "givenName": "Support Guy",
+ "emailAddress": "support@example.com",
+ }
+
+- ``SOCIAL_AUTH_SAML_ENABLED_IDPS``: The most important setting. List the Entity
+ ID, SSO URL, and x.509 public key certificate for each provider that your app
+ wants to support. The SSO URL must support the ``HTTP-Redirect`` binding.
+ You can get these values from the provider's XML metadata. Here's an example,
+ for TestShib_ (the values come from TestShib's metadata_)::
+
+ {
+ "testshib": {
+ "entity_id": "https://idp.testshib.org/idp/shibboleth",
+ "url": "https://idp.testshib.org/idp/profile/SAML2/Redirect/SSO",
+ "x509cert": "MIIEDjCCAvagAwIBAgIBADA ... 8Bbnl+ev0peYzxFyF5sQA==",
+ }
+ }
+
+Basic Usage
+-----------
+
+- Set all of the required configuration variables described above.
+
+- Generate the SAML XML metadata for your app. The best way to do this is to
+ create a new view/page/URL in your app that will call the backend's
+ ``generate_metadata_xml()`` method. Here's an example of how to do this in
+ Django::
+
+ def saml_metadata_view(request):
+ complete_url = reverse('social:complete', args=("saml", ))
+ saml_backend = load_backend(
+ load_strategy(request),
+ "saml",
+ redirect_uri=complete_url,
+ )
+ metadata, errors = saml_backend.generate_metadata_xml()
+ if not errors:
+ return HttpResponse(content=metadata, content_type='text/xml')
+
+- Download the metadata for your app that was generated by the above method,
+ and send it to each Identity Provider (IdP) that you wish to use. Each IdP
+ must install and configure your metadata on their system before it will work.
+
+- Now everything is set! To allow users to login with any given IdP, you need to
+ give them a link to the python-social-auth "begin"/"auth" URL and include an
+ ``idp`` query parameter that specifies the name of the IdP to use. This is
+ needed since the backend supports multiple IdPs. The names of the IdPs are the
+ keys used in the ``SOCIAL_AUTH_SAML_ENABLED_IDPS`` setting.
+
+ Django example::
+
+ # In view:
+ context['testshib_url'] = u"{base}?{params}".format(
+ base=reverse('social:begin', kwargs={'backend': 'saml'}),
+ params=urllib.urlencode({'next': '/home', 'idp': 'testshib'})
+ )
+ # In template:
+ TestShib Login
+ # Result:
+ TestShib Login
+
+- Testing with the TestShib_ provider is recommended, as it is known to work
+ well.
+
+
+Advanced Settings
+-----------------
+
+- ``SOCIAL_AUTH_SAML_SP_EXTRA``: This can be set to a dict, and any key/value
+ pairs specified here will be passed to the underlying ``python-saml`` library
+ configuration's ``sp`` setting. Refer to the ``python-saml`` documentation for
+ details.
+
+- ``SOCIAL_AUTH_SAML_SECURITY_CONFIG``: This can be set to a dict, and any
+ key/value pairs specified here will be passed to the underlying
+ ``python-saml`` library configuration's ``security`` setting. Two useful keys
+ that you can set are ``metadataCacheDuration`` and ``metadataValidUntil``,
+ which control the expiry time of your XML metadata. By default, a cache
+ duration of 10 days will be used, which means that IdPs are allowed to cache
+ your metadata for up to 10 days, but no longer. ``metadataCacheDuration`` must
+ be specified as an ISO 8601 duration string (e.g. `P1D` for one day).
+
+- ``SOCIAL_AUTH_SAML_SP_NAMEID_FORMATS``: This is a list of ``NameID`` formats
+ accepted by your app. The default is not to specify any. Example::
+
+ SOCIAL_AUTH_SAML_SP_NAMEID_FORMATS = [
+ 'urn:oasis:names:tc:SAML:2.0:nameid-format:persistent',
+ 'urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress',
+ ]
+
+
+Advanced Usage
+--------------
+
+You can subclass the ``SAMLAuth`` backend to provide custom functionality. In
+particular, there are two methods that are designed for subclasses to override:
+
+- ``get_idp(self, idp_name)``: Given the name of an IdP, return an instance of
+ ``SAMLIdentityProvider`` with the details of the IdP. Override this method if
+ you wish to use some other method for configuring the available identity
+ providers, such as fetching them at runtime from another server, or using a
+ list of providers from a Shibboleth federation.
+
+- ``_check_entitlements(self, idp, attributes)``: This method gets called during
+ the login process and is where you can decide to accept or reject a user based
+ on the user's SAML attributes. For example, you can restrict access to your
+ application to only accept users who belong to a certain department. After
+ inspecting the passed attributes parameter, do nothing to allow the user to
+ login, or raise ``social.exceptions.AuthForbidden`` to reject the user.
+
+.. _TestShib: https://www.testshib.org/
+.. _metadata: https://www.testshib.org/metadata/testshib-providers.xml
diff --git a/setup.py b/setup.py
index 8860b0a0b..a706a77cd 100644
--- a/setup.py
+++ b/setup.py
@@ -55,7 +55,7 @@ def get_packages():
requirements = f.readlines()
with open(tests_requirements_file, 'r') as f:
- tests_requirements = f.readlines()
+ tests_requirements = [line for line in f.readlines() if '@' not in line]
setup(
name='python-social-auth',
diff --git a/social/backends/saml.py b/social/backends/saml.py
new file mode 100644
index 000000000..0ea11c07a
--- /dev/null
+++ b/social/backends/saml.py
@@ -0,0 +1,289 @@
+"""
+Backend for SAML 2.0 support
+
+Terminology:
+
+"Service Provider" (SP): Your web app
+"Identity Provider" (IdP): The third-party site that is authenticating users via SAML
+"""
+from onelogin.saml2.auth import OneLogin_Saml2_Auth
+from onelogin.saml2.settings import OneLogin_Saml2_Settings
+from social.backends.base import BaseAuth
+from social.exceptions import AuthFailed
+
+# Helpful constants:
+OID_COMMON_NAME = "urn:oid:2.5.4.3"
+OID_EDU_PERSON_PRINCIPAL_NAME = "urn:oid:1.3.6.1.4.1.5923.1.1.1.6"
+OID_EDU_PERSON_ENTITLEMENT = "urn:oid:1.3.6.1.4.1.5923.1.1.1.7"
+OID_GIVEN_NAME = "urn:oid:2.5.4.42"
+OID_MAIL = "urn:oid:0.9.2342.19200300.100.1.3"
+OID_SURNAME = "urn:oid:2.5.4.4"
+OID_USERID = "urn:oid:0.9.2342.19200300.100.1.1"
+
+
+class SAMLIdentityProvider(object):
+ """
+ Wrapper around configuration for a SAML Identity provider
+ """
+
+ def __init__(self, name, **kwargs):
+ """ Load and parse configuration """
+ self.name = name
+ # name should be a slug and must not contain a colon, which could conflict with uid prefixing:
+ assert ':' not in self.name and ' ' not in self.name, "IdP 'name' should be a slug (short, no spaces)"
+ self.conf = kwargs
+
+ def get_user_permanent_id(self, attributes):
+ """
+ The most important method: Get a permanent, unique identifier for this user from the
+ attributes supplied by the IdP.
+
+ If you want to use the NameID, it's available via attributes['name_id']
+ """
+ return attributes[self.conf.get('attr_user_permanent_id', OID_USERID)][0]
+
+ # Attributes processing:
+ def get_user_details(self, attributes):
+ """
+ Given the SAML attributes extracted from the SSO response, get the user data like name.
+ """
+ return {
+ 'fullname': self.get_attr(attributes, 'attr_full_name', OID_COMMON_NAME),
+ 'first_name': self.get_attr(attributes, 'attr_first_name', OID_GIVEN_NAME),
+ 'last_name': self.get_attr(attributes, 'attr_last_name', OID_SURNAME),
+ 'username': self.get_attr(attributes, 'attr_username', OID_USERID),
+ 'email': self.get_attr(attributes, 'attr_email', OID_MAIL),
+ }
+
+ def get_attr(self, attributes, conf_key, default_attribute):
+ """
+ Internal helper method.
+ Get the attribute 'default_attribute' out of the attributes, unless self.conf[conf_key]
+ overrides the default by specifying another attribute to use.
+ """
+ key = self.conf.get(conf_key, default_attribute)
+ return attributes[key][0] if key in attributes else None
+
+ @property
+ def entity_id(self):
+ """ Get the entity ID for this IdP """
+ return self.conf['entity_id'] # Required. e.g. "https://idp.testshib.org/idp/shibboleth"
+
+ @property
+ def sso_url(self):
+ """ Get the SSO URL for this IdP """
+ return self.conf['url'] # Required. e.g. "https://idp.testshib.org/idp/profile/SAML2/Redirect/SSO"
+
+ @property
+ def x509cert(self):
+ """ X.509 Public Key Certificate for this IdP """
+ return self.conf['x509cert']
+
+ @property
+ def saml_config_dict(self):
+ """ Get the IdP configuration dict in the format required by python-saml """
+ return {
+ "entityId": self.entity_id,
+ "singleSignOnService": {
+ "url": self.sso_url,
+ "binding": "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect", # python-saml only supports Redirect
+ },
+ "x509cert": self.x509cert,
+ }
+
+
+class DummySAMLIdentityProvider(SAMLIdentityProvider):
+ """
+ A placeholder IdP used when we must specify something, e.g. when generating SP metadata.
+
+ If OneLogin_Saml2_Auth is modified to not always require IdP config, this can be removed.
+ """
+ def __init__(self):
+ super(DummySAMLIdentityProvider, self).__init__(
+ "dummy",
+ entity_id="https://dummy.none/saml2",
+ url="https://dummy.none/SSO",
+ x509cert='',
+ )
+
+
+class SAMLAuth(BaseAuth):
+ """
+ PSA Backend that implements SAML 2.0 Service Provider (SP) functionality.
+
+ Unlike all of the other backends, this one can be configured to work with
+ many identity providers (IdPs). For example, a University that belongs to a
+ Shibboleth federation may support authentication via ~100 partner
+ universities. Also, the IdP configuration can be changed at runtime if you
+ require that functionality - just subclass this and override `get_idp()`.
+
+ Several settings are required. Here's an example:
+
+ SOCIAL_AUTH_SAML_SP_ENTITY_ID = "https://saml.example.com/"
+ SOCIAL_AUTH_SAML_SP_PUBLIC_CERT = "... X.509 certificate string ..."
+ SOCIAL_AUTH_SAML_SP_PRIVATE_KEY = "... private key ..."
+ SOCIAL_AUTH_SAML_ORG_INFO = {
+ "en-US": {"name": "example", "displayname": "Example Inc.", "url": "http://example.com", },
+ }
+ SOCIAL_AUTH_SAML_TECHNICAL_CONTACT = {"givenName": "Tech Gal", "emailAddress": "technical@example.com", }
+ SOCIAL_AUTH_SAML_SUPPORT_CONTACT = {"givenName": "Support Guy", "emailAddress": "support@example.com", }
+ SOCIAL_AUTH_SAML_ENABLED_IDPS = {
+ "testshib": {
+ "entity_id": "https://idp.testshib.org/idp/shibboleth",
+ "url": "https://idp.testshib.org/idp/profile/SAML2/Redirect/SSO",
+ "x509cert": "MIIEDjCCAvagAwIBAgIBADANBgkqhkiG9w0B ... 8Bbnl+ev0peYzxFyF5sQA==",
+ }
+ }
+
+ Optional settings:
+ SOCIAL_AUTH_SAML_SP_EXTRA = {}
+ SOCIAL_AUTH_SAML_SECURITY_CONFIG = {}
+ SOCIAL_AUTH_SAML_SP_NAMEID_FORMATS = []
+ """
+ name = "saml"
+
+ def get_idp(self, idp_name):
+ """ Given the name of an IdP, get a SAMLIdentityProvider instance """
+ idp_config = self.setting("ENABLED_IDPS")[idp_name]
+ return SAMLIdentityProvider(idp_name, **idp_config)
+
+ def generate_saml_config(self, idp):
+ """
+ Generate the configuration required to instantiate OneLogin_Saml2_Auth
+ """
+ # The shared absolute URL that all IdPs redirect back to - this is specified in our metadata.xml:
+ abs_completion_url = self.redirect_uri
+
+ config = {
+ "contactPerson": {
+ "technical": self.setting("TECHNICAL_CONTACT"),
+ "support": self.setting("SUPPORT_CONTACT"),
+ },
+ "debug": True,
+ "idp": idp.saml_config_dict,
+ "organization": self.setting("ORG_INFO"),
+ "security": {
+ 'metadataValidUntil': '',
+ 'metadataCacheDuration': 'P10D', # metadata valid for ten days
+ },
+ "sp": {
+ "assertionConsumerService": {
+ "url": abs_completion_url,
+ "binding": "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST", # python-saml only supports HTTP-POST
+ },
+ "entityId": self.setting("SP_ENTITY_ID"),
+ "NameIDFormats": self.setting("SP_NAMEID_FORMATS", []),
+ "x509cert": self.setting("SP_PUBLIC_CERT"),
+ "privateKey": self.setting("SP_PRIVATE_KEY"),
+ },
+ "strict": True, # We must force strict mode - for security
+ }
+ config["security"].update(self.setting("SECURITY_CONFIG", {}))
+ config["sp"].update(self.setting("SP_EXTRA", {}))
+ return config
+
+ def generate_metadata_xml(self):
+ """
+ Helper method that can be used from your web app to generate the XML metadata required
+ to link your web app as a Service Provider with each IdP you wish to use.
+
+ Returns (metadata XML string, list of errors)
+
+ Example usage (Django):
+ from social.apps.django_app.utils import load_strategy, load_backend
+ def saml_metadata_view(request):
+ complete_url = reverse('social:complete', args=("saml", ))
+ saml_backend = load_backend(load_strategy(request), "saml", complete_url)
+ metadata, errors = saml_backend.generate_metadata_xml()
+ if not errors:
+ return HttpResponse(content=metadata, content_type='text/xml')
+ return HttpResponseServerError(content=', '.join(errors))
+ """
+ idp = DummySAMLIdentityProvider() # python-saml requires us to specify something here even though it's not used
+ config = self.generate_saml_config(idp)
+ saml_settings = OneLogin_Saml2_Settings(config)
+ metadata = saml_settings.get_sp_metadata()
+ errors = saml_settings.validate_metadata(metadata)
+ return metadata, errors
+
+ def _create_saml_auth(self, idp):
+ """
+ Get an instance of OneLogin_Saml2_Auth
+ """
+ config = self.generate_saml_config(idp)
+ request_info = {
+ 'https': 'on' if self.strategy.request_is_secure() else 'off',
+ 'http_host': self.strategy.request_host(),
+ 'script_name': self.strategy.request_path(),
+ 'server_port': self.strategy.request_port(),
+ 'get_data': self.strategy.request_get(),
+ 'post_data': self.strategy.request_post(),
+ }
+ return OneLogin_Saml2_Auth(request_info, config)
+
+ def auth_url(self):
+ """ Get the URL to which we must redirect in order to authenticate the user """
+ idp_name = self.strategy.request_data()['idp']
+ auth = self._create_saml_auth(idp=self.get_idp(idp_name))
+ # Below, return_to sets the RelayState, which can contain arbitrary data.
+ # We use it to store the specific SAML IdP name, since we multiple IdPs
+ # share the same auth_complete URL.
+ return auth.login(return_to=idp_name)
+
+ def get_user_details(self, response):
+ """
+ Get user details like full name, email, etc. from the response - see auth_complete
+ """
+ idp = self.get_idp(response['idp_name'])
+ return idp.get_user_details(response['attributes'])
+
+ def get_user_id(self, details, response):
+ """
+ Get the permanent ID for this user from the response.
+ We prefix each ID with the name of the IdP so that we can connect multiple IdPs to this
+ user.
+ """
+ idp = self.get_idp(response['idp_name'])
+ uid = idp.get_user_permanent_id(response['attributes'])
+ return '{}:{}'.format(idp.name, uid)
+
+ def auth_complete(self, *args, **kwargs):
+ """
+ The user has been redirected back from the IdP and we should now log them in, if
+ everything checks out.
+ """
+ idp_name = self.strategy.request_data()['RelayState']
+ idp = self.get_idp(idp_name)
+ auth = self._create_saml_auth(idp)
+ auth.process_response()
+ errors = auth.get_errors()
+ if errors or not auth.is_authenticated():
+ reason = auth.get_last_error_reason()
+ raise AuthFailed(self, 'SAML login failed: {} ({})'.format(errors, reason))
+
+ attributes = auth.get_attributes()
+ attributes['name_id'] = auth.get_nameid()
+
+ self._check_entitlements(idp, attributes)
+
+ response = {
+ 'idp_name': idp_name,
+ 'attributes': attributes,
+ 'session_index': auth.get_session_index(),
+ }
+
+ kwargs.update({'response': response, 'backend': self})
+
+ return self.strategy.authenticate(*args, **kwargs)
+
+ def _check_entitlements(self, idp, attributes):
+ """
+ Additional verification of a SAML response before authenticating the user.
+
+ Subclasses can override this method if they need custom validation code,
+ such as requiring the presence of an eduPersonEntitlement.
+
+ raise social.exceptions.AuthForbidden if the user should not be authenticated,
+ or do nothing to allow the login pipeline to continue.
+ """
+ pass
diff --git a/social/strategies/base.py b/social/strategies/base.py
index f2273b972..09ef9fa27 100644
--- a/social/strategies/base.py
+++ b/social/strategies/base.py
@@ -188,3 +188,23 @@ def session_pop(self, name):
def build_absolute_uri(self, path=None):
"""Build absolute URI with given (optional) path"""
raise NotImplementedError('Implement in subclass')
+
+ def request_is_secure(self):
+ """ Is the request using HTTPS? """
+ raise NotImplementedError('Implement in subclass')
+
+ def request_path(self):
+ """ path of the current request """
+ raise NotImplementedError('Implement in subclass')
+
+ def request_port(self):
+ """ Port in use for this request """
+ raise NotImplementedError('Implement in subclass')
+
+ def request_get(self):
+ """ Request GET data """
+ raise NotImplementedError('Implement in subclass')
+
+ def request_post(self):
+ """ Request POST data """
+ raise NotImplementedError('Implement in subclass')
diff --git a/social/strategies/django_strategy.py b/social/strategies/django_strategy.py
index 7e80f03fa..b3b66b791 100644
--- a/social/strategies/django_strategy.py
+++ b/social/strategies/django_strategy.py
@@ -53,6 +53,26 @@ def request_host(self):
if self.request:
return self.request.get_host()
+ def request_is_secure(self):
+ """ Is the request using HTTPS? """
+ return self.request.is_secure()
+
+ def request_path(self):
+ """ path of the current request """
+ return self.request.path
+
+ def request_port(self):
+ """ Port in use for this request """
+ return self.request.META['SERVER_PORT']
+
+ def request_get(self):
+ """ Request GET data """
+ return self.request.GET.copy()
+
+ def request_post(self):
+ """ Request POST data """
+ return self.request.POST.copy()
+
def redirect(self, url):
return redirect(url)
diff --git a/social/tests/actions/test_disconnect.py b/social/tests/actions/test_disconnect.py
index 328ad8d0c..ef89d5939 100644
--- a/social/tests/actions/test_disconnect.py
+++ b/social/tests/actions/test_disconnect.py
@@ -6,7 +6,7 @@
from social.exceptions import NotAllowedToDisconnect
from social.utils import parse_qs
-from social.tests.models import User
+from social.tests.models import User, TestUserSocialAuth
from social.tests.actions.actions import BaseActionTest
@@ -24,6 +24,17 @@ def test_disconnect(self):
do_disconnect(self.backend, user)
self.assertEqual(len(user.social), 0)
+ def test_disconnect_with_association_id(self):
+ self.do_login()
+ user = User.get(self.expected_username)
+ user.password = 'password'
+ association_id = user.social[0].id
+ second_usa = TestUserSocialAuth(user, user.social[0].provider, "uid2")
+ self.assertEqual(len(user.social), 2)
+ do_disconnect(self.backend, user, association_id)
+ self.assertEqual(len(user.social), 1)
+ self.assertEqual(user.social[0], second_usa)
+
def test_disconnect_with_partial_pipeline(self):
self.strategy.set_settings({
'SOCIAL_AUTH_DISCONNECT_PIPELINE': (
diff --git a/social/tests/backends/data/saml_config.json b/social/tests/backends/data/saml_config.json
new file mode 100644
index 000000000..3f610107c
--- /dev/null
+++ b/social/tests/backends/data/saml_config.json
@@ -0,0 +1,23 @@
+{
+ "SOCIAL_AUTH_SAML_SP_ENTITY_ID": "https://github.com/omab/python-social-auth/saml-test",
+ "SOCIAL_AUTH_SAML_SP_PUBLIC_CERT": "MIICsDCCAhmgAwIBAgIJAO7BwdjDZcUWMA0GCSqGSIb3DQEBBQUAMEUxCzAJBgNVBAYTAkNBMRkwFwYDVQQIExBCcml0aXNoIENvbHVtYmlhMRswGQYDVQQKExJweXRob24tc29jaWFsLWF1dGgwHhcNMTUwNTA4MDc1ODQ2WhcNMjUwNTA3MDc1ODQ2WjBFMQswCQYDVQQGEwJDQTEZMBcGA1UECBMQQnJpdGlzaCBDb2x1bWJpYTEbMBkGA1UEChMScHl0aG9uLXNvY2lhbC1hdXRoMIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCq3g1Cl+3uR5vCnN4HbgjTg+m3nHhteEMyb++ycZYre2bxUfsshER6x33l23tHckRYwm7MdBbrp3LrVoiOCdPblTml1IhEPTCwKMhBKvvWqTvgfcSSnRzAWkLlQYSusayyZK4n9qcYkV5MFni1rbjx+Mr5aOEmb5u33amMKLwSTwIDAQABo4GnMIGkMB0GA1UdDgQWBBRRiBR6zS66fKVokp0yJHbgv3RYmjB1BgNVHSMEbjBsgBRRiBR6zS66fKVokp0yJHbgv3RYmqFJpEcwRTELMAkGA1UEBhMCQ0ExGTAXBgNVBAgTEEJyaXRpc2ggQ29sdW1iaWExGzAZBgNVBAoTEnB5dGhvbi1zb2NpYWwtYXV0aIIJAO7BwdjDZcUWMAwGA1UdEwQFMAMBAf8wDQYJKoZIhvcNAQEFBQADgYEAJwsMU3YSaybVjuJ8US0fUhlPOlM40QFCGL4vB3TEbb24Mq8HrjUwrU0JFPGls9a2OYzN2B3e35NorMuxs+grGtr2yP6LvuX+nV6A93wb4ooGHoGfC7VLlyxSSns937SS5R1pzQ4gWzZma2KGWKICWph5zQ0ARVhL63967mGLmoI=",
+ "SOCIAL_AUTH_SAML_SP_PRIVATE_KEY": "MIICXgIBAAKBgQCq3g1Cl+3uR5vCnN4HbgjTg+m3nHhteEMyb++ycZYre2bxUfsshER6x33l23tHckRYwm7MdBbrp3LrVoiOCdPblTml1IhEPTCwKMhBKvvWqTvgfcSSnRzAWkLlQYSusayyZK4n9qcYkV5MFni1rbjx+Mr5aOEmb5u33amMKLwSTwIDAQABAoGBAIHAg6NJSiYC/NYpVzWfKlasuoNy78R5adXYSNZiCR5V5FNm5OzmODZgXUt6g0A7FomshIT/txQWoV7y5FmwPs8n13JY3Hdt4tJ6MHw2feLo710+OEp9VBQus3JsB2F8ONYrGvs00hPPL7h5av/rzTdE8F67YM1mSgeg7xEF6BghAkEA12OOqSzp2MLTNY7PqOaLDzy4aAMVNN3Ntv2jBN0jq7s1b5ilQ2PGkLwdtkicq/VZcRyUqVbZbMwz05II3nqx3wJBAMsVhRQ5sdFCRBzEbSAm2YEJaFh5u6QT3+zWHMFpPJRnaBAWz3RXKEnleJ+DS2Xz1Jm6ZrmLdZiwMx/8dK5rDZECQQC7GTdWi7ZC3dIcpwaKIGHRhZxmda8ZMkc9Wwwd8H7I8aFUZFPCu0xEc7SXoHHACit8zyfwBYpvMN8gPK3JnOkfAkEAsUSpk0wBMT38one7IZOHzCDgGkq4RbKrhdon45Pus0PIDDM9BrqFimtpbSN4DxhVfZK91DwtfAhhuAvv9cewYQJAPMhpAqv3PBGYmtRDUlWXJQv2JRJJkrvbbqgBed2OX5RRgj5V3SR6PBhLbcTZ+q+1tdPkMFzZo5U6MN5m/6oXvQ==",
+ "SOCIAL_AUTH_SAML_ORG_INFO": {
+ "en-US": {"name": "psa", "displayname": "PSA", "url": "https://github.com/omab/python-social-auth/"}
+ },
+ "SOCIAL_AUTH_SAML_TECHNICAL_CONTACT":
+ {"givenName": "Tech Gal", "emailAddress": "technical@example.com"},
+ "SOCIAL_AUTH_SAML_SUPPORT_CONTACT":
+ {"givenName": "Support Guy", "emailAddress": "support@example.com"},
+ "SOCIAL_AUTH_SAML_ENABLED_IDPS": {
+ "testshib": {
+ "entity_id": "https://idp.testshib.org/idp/shibboleth",
+ "url": "https://idp.testshib.org/idp/profile/SAML2/Redirect/SSO",
+ "x509cert": "MIIEDjCCAvagAwIBAgIBADANBgkqhkiG9w0BAQUFADBnMQswCQYDVQQGEwJVUzEVMBMGA1UECBMMUGVubnN5bHZhbmlhMRMwEQYDVQQHEwpQaXR0c2J1cmdoMREwDwYDVQQKEwhUZXN0U2hpYjEZMBcGA1UEAxMQaWRwLnRlc3RzaGliLm9yZzAeFw0wNjA4MzAyMTEyMjVaFw0xNjA4MjcyMTEyMjVaMGcxCzAJBgNVBAYTAlVTMRUwEwYDVQQIEwxQZW5uc3lsdmFuaWExEzARBgNVBAcTClBpdHRzYnVyZ2gxETAPBgNVBAoTCFRlc3RTaGliMRkwFwYDVQQDExBpZHAudGVzdHNoaWIub3JnMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEArYkCGuTmJp9eAOSGHwRJo1SNatB5ZOKqDM9ysg7CyVTDClcpu93gSP10nH4gkCZOlnESNgttg0r+MqL8tfJC6ybddEFB3YBo8PZajKSe3OQ01Ow3yT4I+Wdg1tsTpSge9gEz7SrC07EkYmHuPtd71CHiUaCWDv+xVfUQX0aTNPFmDixzUjoYzbGDrtAyCqA8f9CN2txIfJnpHE6q6CmKcoLADS4UrNPlhHSzd614kR/JYiks0K4kbRqCQF0Dv0P5Di+rEfefC6glV8ysC8dB5/9nb0yh/ojRuJGmgMWHgWk6h0ihjihqiu4jACovUZ7vVOCgSE5Ipn7OIwqd93zp2wIDAQABo4HEMIHBMB0GA1UdDgQWBBSsBQ869nh83KqZr5jArr4/7b+QazCBkQYDVR0jBIGJMIGGgBSsBQ869nh83KqZr5jArr4/7b+Qa6FrpGkwZzELMAkGA1UEBhMCVVMxFTATBgNVBAgTDFBlbm5zeWx2YW5pYTETMBEGA1UEBxMKUGl0dHNidXJnaDERMA8GA1UEChMIVGVzdFNoaWIxGTAXBgNVBAMTEGlkcC50ZXN0c2hpYi5vcmeCAQAwDAYDVR0TBAUwAwEB/zANBgkqhkiG9w0BAQUFAAOCAQEAjR29PhrCbk8qLN5MFfSVk98t3CT9jHZoYxd8QMRLI4j7iYQxXiGJTT1FXs1nd4Rha9un+LqTfeMMYqISdDDI6tv8iNpkOAvZZUosVkUo93pv1T0RPz35hcHHYq2yee59HJOco2bFlcsH8JBXRSRrJ3Q7Eut+z9uo80JdGNJ4/SJy5UorZ8KazGj16lfJhOBXldgrhppQBb0Nq6HKHguqmwRfJ+WkxemZXzhediAjGeka8nz8JjwxpUjAiSWYKLtJhGEaTqCYxCCX2Dw+dOTqUzHOZ7WKv4JXPK5G/Uhr8K/qhmFT2nIQi538n6rVYLeWj8Bbnl+ev0peYzxFyF5sQA=="
+ },
+ "other": {
+ "entity_id": "https://unused.saml.example.com",
+ "url": "https://unused.saml.example.com/SAML2/Redirect/SSO"
+ }
+ }
+}
diff --git a/social/tests/backends/data/saml_response.txt b/social/tests/backends/data/saml_response.txt
new file mode 100644
index 000000000..557bb59e8
--- /dev/null
+++ b/social/tests/backends/data/saml_response.txt
@@ -0,0 +1 @@
+http://myapp.com/?RelayState=testshib&SAMLResponse=PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiPz48c2FtbDJwOlJlc3BvbnNlIHhtbG5zOnNhbWwycD0idXJuOm9hc2lzOm5hbWVzOnRjOlNBTUw6Mi4wOnByb3RvY29sIiBEZXN0aW5hdGlvbj0iaHR0cDovL215YXBwLmNvbSIgSUQ9Il8yNTk2NTFlOTY3ZGIwOGZjYTQ4MjdkODI3YWY1M2RkMCIgSW5SZXNwb25zZVRvPSJURVNUX0lEIiBJc3N1ZUluc3RhbnQ9IjIwMTUtMDUtMDlUMDM6NTc6NDMuNzkyWiIgVmVyc2lvbj0iMi4wIj48c2FtbDI6SXNzdWVyIHhtbG5zOnNhbWwyPSJ1cm46b2FzaXM6bmFtZXM6dGM6U0FNTDoyLjA6YXNzZXJ0aW9uIiBGb3JtYXQ9InVybjpvYXNpczpuYW1lczp0YzpTQU1MOjIuMDpuYW1laWQtZm9ybWF0OmVudGl0eSI%2BaHR0cHM6Ly9pZHAudGVzdHNoaWIub3JnL2lkcC9zaGliYm9sZXRoPC9zYW1sMjpJc3N1ZXI%2BPHNhbWwycDpTdGF0dXM%2BPHNhbWwycDpTdGF0dXNDb2RlIFZhbHVlPSJ1cm46b2FzaXM6bmFtZXM6dGM6U0FNTDoyLjA6c3RhdHVzOlN1Y2Nlc3MiLz48L3NhbWwycDpTdGF0dXM%2BPHNhbWwyOkVuY3J5cHRlZEFzc2VydGlvbiB4bWxuczpzYW1sMj0idXJuOm9hc2lzOm5hbWVzOnRjOlNBTUw6Mi4wOmFzc2VydGlvbiI%2BPHhlbmM6RW5jcnlwdGVkRGF0YSB4bWxuczp4ZW5jPSJodHRwOi8vd3d3LnczLm9yZy8yMDAxLzA0L3htbGVuYyMiIElkPSJfMGM0NzYzNzIyOWFkNmEzMTY1OGU0MDc2ZDNlYzBmNmQiIFR5cGU9Imh0dHA6Ly93d3cudzMub3JnLzIwMDEvMDQveG1sZW5jI0VsZW1lbnQiPjx4ZW5jOkVuY3J5cHRpb25NZXRob2QgQWxnb3JpdGhtPSJodHRwOi8vd3d3LnczLm9yZy8yMDAxLzA0L3htbGVuYyNhZXMxMjgtY2JjIiB4bWxuczp4ZW5jPSJodHRwOi8vd3d3LnczLm9yZy8yMDAxLzA0L3htbGVuYyMiLz48ZHM6S2V5SW5mbyB4bWxuczpkcz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC8wOS94bWxkc2lnIyI%2BPHhlbmM6RW5jcnlwdGVkS2V5IElkPSJfYjZmNmU2YWZjMzYyNGI3NmM1N2JmOWZhODA5YzAzNmMiIHhtbG5zOnhlbmM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDEvMDQveG1sZW5jIyI%2BPHhlbmM6RW5jcnlwdGlvbk1ldGhvZCBBbGdvcml0aG09Imh0dHA6Ly93d3cudzMub3JnLzIwMDEvMDQveG1sZW5jI3JzYS1vYWVwLW1nZjFwIiB4bWxuczp4ZW5jPSJodHRwOi8vd3d3LnczLm9yZy8yMDAxLzA0L3htbGVuYyMiPjxkczpEaWdlc3RNZXRob2QgQWxnb3JpdGhtPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwLzA5L3htbGRzaWcjc2hhMSIgeG1sbnM6ZHM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvMDkveG1sZHNpZyMiLz48L3hlbmM6RW5jcnlwdGlvbk1ldGhvZD48ZHM6S2V5SW5mbz48ZHM6WDUwOURhdGE%2BPGRzOlg1MDlDZXJ0aWZpY2F0ZT5NSUlDc0RDQ0FobWdBd0lCQWdJSkFPN0J3ZGpEWmNVV01BMEdDU3FHU0liM0RRRUJCUVVBTUVVeEN6QUpCZ05WQkFZVEFrTkJNUmt3CkZ3WURWUVFJRXhCQ2NtbDBhWE5vSUVOdmJIVnRZbWxoTVJzd0dRWURWUVFLRXhKd2VYUm9iMjR0YzI5amFXRnNMV0YxZEdnd0hoY04KTVRVd05UQTRNRGMxT0RRMldoY05NalV3TlRBM01EYzFPRFEyV2pCRk1Rc3dDUVlEVlFRR0V3SkRRVEVaTUJjR0ExVUVDQk1RUW5KcApkR2x6YUNCRGIyeDFiV0pwWVRFYk1Ca0dBMVVFQ2hNU2NIbDBhRzl1TFhOdlkybGhiQzFoZFhSb01JR2ZNQTBHQ1NxR1NJYjNEUUVCCkFRVUFBNEdOQURDQmlRS0JnUUNxM2cxQ2wrM3VSNXZDbk40SGJnalRnK20zbkhodGVFTXliKyt5Y1pZcmUyYnhVZnNzaEVSNngzM2wKMjN0SGNrUll3bTdNZEJicnAzTHJWb2lPQ2RQYmxUbWwxSWhFUFRDd0tNaEJLdnZXcVR2Z2ZjU1NuUnpBV2tMbFFZU3VzYXl5Wks0bgo5cWNZa1Y1TUZuaTFyYmp4K01yNWFPRW1iNXUzM2FtTUtMd1NUd0lEQVFBQm80R25NSUdrTUIwR0ExVWREZ1FXQkJSUmlCUjZ6UzY2CmZLVm9rcDB5SkhiZ3YzUlltakIxQmdOVkhTTUViakJzZ0JSUmlCUjZ6UzY2ZktWb2twMHlKSGJndjNSWW1xRkpwRWN3UlRFTE1Ba0cKQTFVRUJoTUNRMEV4R1RBWEJnTlZCQWdURUVKeWFYUnBjMmdnUTI5c2RXMWlhV0V4R3pBWkJnTlZCQW9URW5CNWRHaHZiaTF6YjJOcApZV3d0WVhWMGFJSUpBTzdCd2RqRFpjVVdNQXdHQTFVZEV3UUZNQU1CQWY4d0RRWUpLb1pJaHZjTkFRRUZCUUFEZ1lFQUp3c01VM1lTCmF5YlZqdUo4VVMwZlVobFBPbE00MFFGQ0dMNHZCM1RFYmIyNE1xOEhyalV3clUwSkZQR2xzOWEyT1l6TjJCM2UzNU5vck11eHMrZ3IKR3RyMnlQNkx2dVgrblY2QTkzd2I0b29HSG9HZkM3VkxseXhTU25zOTM3U1M1UjFwelE0Z1d6Wm1hMktHV0tJQ1dwaDV6UTBBUlZoTAo2Mzk2N21HTG1vST08L2RzOlg1MDlDZXJ0aWZpY2F0ZT48L2RzOlg1MDlEYXRhPjwvZHM6S2V5SW5mbz48eGVuYzpDaXBoZXJEYXRhIHhtbG5zOnhlbmM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDEvMDQveG1sZW5jIyI%2BPHhlbmM6Q2lwaGVyVmFsdWU%2BTElQdkVNVUVGeXhrVHowQ2N4QVA5TjV4Y3NYT2V4aVV4cXBvR2VIeVFMV0R5RVBBUDVnZ1daL3NLZ1ViL2xWSk92bCtuQXhSdVhXUlc5dGxSWWx3R2orRVhIOWhIbmdEY1BWMDNqSUJMQnFJbElBL1RmMGw4cVliOHFKRy9ZM0RTS2RQNkwvUURtYXBtTXpFM29YOEJxMW5Ea3YrUWh4cmQwMGVGK2ZMYVQ0PTwveGVuYzpDaXBoZXJWYWx1ZT48L3hlbmM6Q2lwaGVyRGF0YT48L3hlbmM6RW5jcnlwdGVkS2V5PjwvZHM6S2V5SW5mbz48eGVuYzpDaXBoZXJEYXRhIHhtbG5zOnhlbmM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDEvMDQveG1sZW5jIyI%2BPHhlbmM6Q2lwaGVyVmFsdWU%2BRVpUWDhHTkM0My9yWStTUVlBMXRudHlUTTVVNkN2dUNCaktsVEVlekZPRjBZZHhCWUdFQVVjYU8xNVNKOXBMemJ1L1h0WGxzTkVMZTdKdEx4RUpwYUxubWFENnIranNWczdLaTBLNHRTMGNBUERDWHV2R1FoMmFOVjVQOGJ3N1JWUGhLOGQwYlJ1RklGR09FOHMwTTZYOUpxWDN4S0MvL1lSbVVoeDlybnU3ZWlwMGh5ZitPaUZiVGR2SDY2NTB2LzQ3aVdKcDNZeFlUV0QyMHBNbVRJMUpwWUEwYjByWVFQRkR0RU93d0JxYktxanRJc3ZYVFJzeXJhQkxvbnFOeHN5dHpEWHEra0JsMXp3WGUvSE5QcUVQblczdnNxaFhZcDVGM3dkWThkKzNCOTRMZlpOdUd4a0p3VDNzdVR0OGY5VHRBSlI4VytBUmtzT2M4eDBVaVNsVG5BNHFHOTBLMTR5dkVoVHcvd2drZjFXV01RT3dpZDNpakFYbUV4MU5MbVZvYUxYb3p4VExkTjN6YnJ6VEJIRXc3R2J3ZEdrdU5pMlhZOW16YUgwaWtGRm51VUxjMHUwc0pycEdGdzlaK0VlUk44RzNVUVZ5MjhtS2g3ZFBwWU5KbzhyajIxZFFaK2JaeUtTUHZablU3REkyakdJRE5US1g2ZkVyVWFINGlOTzN4cUU2Vk90L2d4T3BMNE5VNUhLV0Q0bG93VzcwdUJjVEVQRmhwaThpYUovdTB6YzUvTEhvdVBjMzByc1RLZFc5cmJLL2NWaHNQUHErZzA5WHZpZ0QweTJvN2tOc1pVL25tRXFiSzBKOTBrazhCR3I5cXRSczY4bUJnSURtUHVwUkhwWjM4eXNnU2VZN3V0VlVaSG5tQ0dzTzZ2NDJ6OTVOK05Pb3RCTEVZbFd1ZEdzYnowQWc4VkRDSlY5ak95QW95MDZyL1AyUHBsOFhjdmJza2d2T1BMMWdDNnVYbVJJS1lmOEw4UDJCNXVjN0haK0dtUHNOWXRLS2VKRDFFUHovdCt2NlBIbXNVb3dsSDhSd3FMRHdtMUF4dlNLQTR3UXBlQ0dQd3A5YXRYS0lWMS84NUZzRWMzajVzNjd6VlRybThrVEpydXV2MDZEdFVRZDNMOFdwTkV4cWhQait6RUp6U3RxSG04ckhNMVhNQUVxdVozc0xycTVqLzFSNlpqS0dOdFJCbjhwOE5ERGtrWm0vWTV5TXlJNXJJS3U5bnA3bXdaaEVpeWVHeHdxblV3VVMvUzVDRjNnMHVidnd4eVVnalVvd1ZvTkNqYktBbkdtT2VCSW5abkh0eGdIVUhVOUVlTFdyd2pRc3JtUmpJV0R2RkZQa3l6SzJDL20yaitubmNxc2E1OGRLVXZxcGR1VTRJYnNPQng3UGpXdXRBNmY5bXd6YWxyRU1NK0lGR3VPdk9HMC93eUdzQjZLREV6bldjUC83NkQ4angzaHZFSlAzN3REbFgreGM4Qno5TXdKdkd6VG4xbTdCb2xoR0lzSXlCTys1ZXpXa3RDWVVIUURGVE9wbXA0MDlOWHp6ZUNTUGY1U2NDWG5YYjRPd01ULy9VM1JFUnRRbGMrNmU2WG1JRjhoRkJVc0taUUJsS2ppSDkwZHlzYWlsNmN2V3UyQW55Q3QxbWxXcHFLc0MzU2RTRVZDTG1qRjlUQUFUMEtFSGdZQjg3RjZtZUpTTysvOXkyZkRuYVVvUUlUVzdubnVuSCtkT3dWSGZMU0wyL2N5YTltNlQzR29TSVNMbGJPMVRzalhKclVkZW55OTcvM2tkNmhFQlphdGY1U3NETFQ3SjNsQUVJNDROeXJ0NkIxQWdod2JNdkpqd1JNTXRNdUJLc3ltUytKVzc4UFNEWXQ4MG9waDJQTTc1N0tBNCtUMTAvYnZaQkE5Vk1OdVpqNVV3NXRWMnFIS3dwS0t6ZVVETUFiQlBRaGpYcXlQZzFKa09rd2RQMUpnOHRITjJTelBZQTlmT1htV0pBZGJDS2tMb0F4ZTV6cDZBUzYzS3FXMmFmSUt6SHJ3RTJmS1VtamppeURvMnNuMkJHbWtBaTRzbnpiVzc2SUQvSVgwd044aDBaQ2VRc29vKzdtb1RCMEJxSnBkS1MycXlsUktoc3BSTC9henVQdmxaK1pwckJxdXpJdEZkNFVLMkpzQkp6VXcwZkpxcTV1bk9PZENzVWM3SUU3QTNmZ1NmZ3NBd1R3WFZJMEVoME5ySWZpMkFKV1Z2VFpEMys2eFZ3dS96WWhuVjc0VXkvMFE4Mi8yQWtpSGpFRjNJVGNLWHdTNTB6bWtLakxjZDJqa2h5TUFYMWRoQ0wwZElFMUJoN0RNamVvNC9YbjBqSlpPL3Rrbi9xZmYzc3RNb1BYVG9KTnBIU1RjR2ZheGtaMzJYNCt3Q0xPc0VBRWxlMVZSY0kwUkZyOFhHTSsxWU9BTjBodFdGcFMxaG9kSi9OczJqL1FnUVNEemNpQ1FZeUFDd3lFRWZDZjZybnR0VmJyTlJQZWlmSHhBM3B2UnZ5ZGRhNDE5cXl0ZXI0akJ3cmw3ZUpuVnJ2VEprR2VhU2FRbDdXWk5SQXBscXRnNnZPYmpiMHZDRWlFaFhKbmNzQUhxcXp5QTRGeWFUVGQ2R0FySU9adUNxRWVoWk51T01lOVlrMVpya0VkR3pIalJESWk3Q1BKQk12NEZ4ZHI3bnJvN0I1WEhKb0ZMNE1DSUtOWWU2aWZiTUtYOU5uN1FWdnphUmY2UXlaSW1BWENQZndvU1BkN2x6NXl3UDJLSUIyaGhFMWt5eVZ5YVc5T0praWpUY3dvUnZrSXhIU0RqMXFqeGxueXh0QzhVZ1pNWmlwcGgzQXJpcjRiekIzUDhIbGIzejZ0OW51KzZMemNiN2ZObVo0UHluaU50Vk9OQ0lHbEh4dTBSY3hQK3cwUXNsM1BtTzJLaHBpc2RIanhvSUJ1YVY1NXdoTlFFNmdNNFBrT0xINDc4Rzg4bUxkd2s2RFpkWVl4L2d6RWE3b3ZIL0pReFp2TzRLdFVTNmZjZHJxV2thTFg1cEhkNkdneFBGZ2NFc2Nad1ZqM2hCS0xFQmE5L0dodERINEhzRnNRbmpPZnNDQkNzN0tjRitmTi9oSUdUeHFqTVlKVHJRYmNtdWF5dk9xR3RQMDFPcXltR24rVm5FSVkzKytQcm95SFN3K0Q0b0JIVG1maFNXRmJLZCtuTlVFS3BhRVIxNkdCU256WktQRVRVSmdRWEw5QWJRQ3RXVjFHb0UzRWNnMDZYaVd2aHFHakpGNldtdEU4dHY4Q25rZmxMNm91TDRvNldpbmx2WnNEdkZrS0R6TDkwUTNsWC9NanBtRTFpWU9uYzdISXdEVGwraFRRcHdsYXJiTDVUNGNkZTg1akNwYU0xU3p1TStiQU5zMHlXVDA0ZXJUVFc2cnhlbXFDTHAra202TVVMTlZOcE1CazBiQjJpRU82UlRtc3VpRlhDUU1xdU5xZjdkWXUwTFFCZzQ0MkJzU1pBV1ZrWEVZblduOURLdTRSby8veEFsb2h5VHozWlZmSkhuWVBSdDloSUErRHVUL3c4T2ZzTURIWnlCelUvL0JEa1NiNkxjMHdraVA3QlhIdjBoNVdud2dNWUxlZDBPalR5UWI2aGxpVnQ5b0FjaDRFVy9EZUlBdkpaQ1BYVm1pUFFYTGVsOVJIRko2bXFiYVo0TCtaZG1ONmQwcFZNZ1FveXhmQTR3dEwwYVpiNnFZYkhibjJMd2VBQVZwL3M2TzVlMVExdnZpZDRTWHo0a2l3RW1LSStIeXZEQ1pnekpQQVN5Z1gvWDJFWEZ0NGV3SjVmUFQyVXZmWnhQWlpqMFZGSFpyUFQwWVd2VE16bjUva3hoT09oM2drVGdDSmNwNWVsZnp4cEFPNFl1a0NoNHJXdVNndDRqVUJyaWNYbFdWdWo5U3JSZVhUalNHTktLK202NWovUDllNHRHT0RkMk9BbjNKTVQ3Q3FuaDhreTZpZjVjbmpVMmU3UDhTZnBONGwxWEFiZEZEcGk5bVJYamEyTzR1RWFHNGNvNW4xcWNDT3ZNMWYyblFBY1ZGNUFoSXhueS96TWhmU2l2RXdOQ0Zyd2tBWDRyQVE0WldUNldFakFyUG5jb1Y4Z1VRclhxQVA4NDJmK1lNWWI5RHFncmFicEg1a3ZuMnQzcWRldGJHODJ0QWlTamhPcUxNYW9iU2F4cXdWa1lUOHRTMW9rUUt2MWZoZ2t6elpEOE5IQnVQQzdNVHdXS0VCS2tDRUUzRWRFMXhNQURLd1B1M3NSaGpSaExXZyszZ2srejJtdlU4cTBhTlc0Y3hObUdoekx4eEY0Q3NFNStMQ1cwOWFpUVJOM1VvWmg1aktBZzBiMlh3WHBLS3pycUVTY1BYdnI0L1dWUTMyMm5qRWRvQVdXR0t2WnBKMlRlREo0eDdiT21LVElFc2RHWU1UZzFVaEU2eFFQcnhqS3dWeGFJNVJyaVE4a0xpaGgwa0t0WHQvYTVsSDhzUjVwR0ZISGZ3dlNVb3liQTB1eUVDNnNRVitPbTVReUZmRmpqZHFCOGNpOGxQS1hLTHFCTHJ6bjNmUkh3TmQwbzFiRTg0aGllTkx5UlhZVmhrRCtFNEpGaVd3ZWt3U3VWM3BjQk9ybnRVU3RoWmx6M3hIUURUVGNJNWliOFJyQ2swZEZ6YTgvQmw3VUdtWlUwSXZ2UmdvVXF2TXNHT2dMY3pGWmRpZnJ5aGNiUTY4a2ZzZ3lCMHppdC9MN1BSV3V4RkdYdDFoTVZSVUZ3WXBJS04zVkI3cXVKZlgwamZsU1JaRndMaXdlK3VhYndmTVZ6c2doajUvOXZNNzcwK0JaMGtJcE45NzBTMG5BbHl6R0h0aW1nTUl1RXFhbUt5QTNTQlI1aHZIYmRyNENnTHFUbXIzbFFnWmpnSkNvN1FXYUJWTXdCR0RpdzVOVVhUUnBycWc4U3h2eDlnNWZwbXMrL0o2QjFEelNTM3ZRZzgxdHFRU1ZDWVJpc0Y3M2VqZlFuZk4zcUszd3RJRDkxQnRISmFvMEFaUUdKVFpKOXVsZ0kzV3hzdWR4ejB0VHVpNlJlSWpmSWsxekZRdFpwRExGMnB3NGpTQVdQTlJqNDBYdVIrRzFUVlI3OVFiME9FYkw4RDFoTU5zWmo3MTZNbUhSOTlKaUxNdm1FWHV5a1V4VGhGYjRMTzZVbW1kU3UwTlBpMXQ2NmNkYURpQWhMaVBFTGdUNkZsenA2T2FGSGNSNjRncEtyemtTNDJONEhJeFpNa2R6M0FsYkRhK2pOWHZPR1l3UWl5K0xNNENZWGtrTWtHR3ZTWis5R2xWQ0l5RXBJaXIzbEQ3bmdzZGk4emxGWDYvekNaczlQSUtwZFZlSGJGZi9GS20wV3AreHI0Ykd0R0RrVHR2Nk1Manh2YU8zanFHaUFWeERKVWFkTVBlS2VHSm5uempTdnpKbGdOVHV3c3grRnF5L2dPMkwxMGowWmhDWi92dE9NelVjNjl3cGhKZm9FNzU3V3lOeFJOcThJc0Y1Tkg5Y0x0b3UvbUNxOTc3YnZPSkRrSURCN3lKWEJ6YUhVQkJuSXJra1Qyemg3bGJmUm5SREJUSFZraVZMazVESUxqeC9XL1BSZEZpUUM2SzRmZGx4Y29JbzlMcnM4ZFVWZkt2TTNNYnJ6c1hGT3ZtVVh0K3NsZldvd3UyTC9ndG9mRFhvTUJZZnlEcWIvWlRaRWZ0MC83blliRm1relBEUlZacU5SR0F3YWZVNTU1UjB2SWtNbGR2VjdKUzhNT1BNYWlXQVBpelNLRG4yRzNvcys1MzRFQytaOGZnWmFPVWpZL0xLME9vME9RMmhvNUV6MGNMYWpwUjFINk9FNEhvUm1ydjQzZkFjdGpYc0hYdi81RXg3emdrWk1NZXZhTFNEdjZtcjFGcDk4QXR4L296VTFGVDBoMDUxcVcwR0g2VWpRRXk5aExSZDBBMnFkUTRMZXpReDNvbDFTblhsamt2MG4zTXFlaFozOC94bzZhdHFDdkJtQkc3amlUdXd6YnlVUngzRm1TM0NCNllOYnFON3hPYVRZRnlkOEZDL01nY0xGQmMwS3F4MXllQ2VUd1hucldQb0dvdllVQlYxYjA1cWtIa1d5V0RUaCsveXJFNzF0RjNxbUQvd3F6cUJyNE04NERtWWVuQkdFOWxtb3FIZEMyWnRpK09KVFZKcmlHZWxQQ3RjZnZRaUlQcHdDZ3BFNmg1ekZhRndLajRuZGtBUkRpTC95L1EwWTZxNU5rM1g5RURlTmdjY1pIcFdmOUpKQ3M2a29wdXRtYjdDczIrbVJYdER1S09DaGY5UVUyN3Bmb1NJaklYK3NGdHY1c0hhSms2aHBZMlpzUUhzaTBYbFowc3FMTnQ5ayszdTVnYnBSU1JCczlHaC9BaVY0dkNyYTRkOTh5U0dCdzRSR1FhSStpQ29RaG9YK3lxc3VrYkx6bXJUU3FXMVRXaXJReUlHZ1Q5VnFERE1mUzAxeGdQSlNFSTlIWlp6TGlFVXVGMm1CMi81Y2dqaEFUaWQrdGV1UVB4aldhN2NSc2t5YUhuTENjQURVUU9ESUFPVjJDWXROcnAwY29ZL091S3ZzaXlJT0lacVJ5dE1PMGVNZ1ZJWTBzWmdxeVEycXlubUx0NDBmWmd3SFVyV245Zm9TYTNtMkVRTy9uOS8yU2NuelJWdVZpVnNjM0tCSElQL3AzNlJlSWowTGlNcCtPQ0p3SHlLVW1UeDRBU1V0dXVhWktlRHl1QjlxcXJuUEFNWUVCeElsTGFvdXMzV1pHakIrcW9ub3QvNmk1UE40bUZjbHFDcUxhMGJHbks4ZnJxYy9yd2tuVGV0YUE0c2tXTEw1L21qNEd5MitFQkh3a0x3UXd2K0FKdmZTOXYvNDl1LzY0N1ZFYW15UzdZQ2ZEUHNBQUREQ1FFcWJNQ1h2Ui8xVmEwWi9YUWhoNlkrZUt0MEVpRDdpNmRZODJtQkFoNEJMRmRVV3VGZHVrdUVwaGZ2WXB3N2loVjNxTjB1NFM1NTRXU0dUa0ZsdlpYNG1hbkF4a1g2ekQxS0NWaEFMdEJnSDgzdkhxam9uc0lwOFMydHgwZ0tiYzEreHVaRVppVWlNVVlVdTByQVFsRFcrZHJoN3lVRHZqekFHSnBmTk01eThaMW45em93VzZ5YW5VZWFBNjhSZDd5TUxobFd0NVh6bGhBTVZDZmZYZ0pFelR1YzJEbENVOXNMLzVTVkRaV2N4R1E5aFM1cnJtK2VyQ1Jxd2FJQk1DNUtza0RCZHdOWmh2Q0FCdEpqS2Vla1FUSjd5MFp4SGNhbGVCaU1rbkYwZVRDZzFvUEhPUVZLQ3V3NE94cHRZUS9xS1V0TEFIWFZ2OTlLMGRWcWZDMmpVQWlHQmVYa0t3aGRYTGtJYlZxU0EyZmxraXBBeEhYNnByUEExQjF3eTVab3hPUFg4RVExOW92eXpBbFg1dHU0OXEwWC9PSExFN1o5T1cxenltRXR6ZFpyNXJZbWtFcVdtcHVSNU5jeHFwTWlZam93dUNXZWhubzIyeG5JM09IQ0xDZkFKaHRrcklhL1hPc0tZRFpCRzFJMGJsN2taR2R5cEtUQlhYdXl6WE5WUlU5L005ejhaVytwdG1oZ2NOUzBJS2VaaSs5bFl4cWRlS3lnbldTTTV3czdSYUpmNlRRZTNSaWJZUjFvNkhwRzB2VHpiTEtQZTZnRjJGODdiWlBJei9mcTNLWnZiM3UrSnhZcCtJVjBtQi9VN29YelhRRk1RK3VmWllpNzUxbkx6WlVxRE1ybU53TFJPVUFNUk8rVnJtblkwSVB1cFBVMXc0b0hBb1dnVGRnTk5pNk1uTFQ4V0pmUlhjT0pKMk1lbUc2K2ZNeHNZUU52UVJwa1RGY05vaFV6Y3ZjcHJ3NUV3WEVZQTJzbzczL2MvY3RIRGcreU05YlF4REppUlltRnFydkhYb29hS1JyekxnUjZLVWdoM3ltaWxaQ0lSSm9KbTE3aEtHM1pxTTE0Lzl5OUc5OE9BZjNkVTlqMDk3aUNlaEc3a2VxYXRJQ2hFWmJqbmQ4Y00rS3djN2FtVWp2ekQzQmNvMHl3MDJxT054OWF3OGhSblZiWDZhdkRJbGhySHZ6SU44MzFvUjljRHBwMG1DUEJXZFVDQlNqVGJ1RkZqRC90WElSbGxlT2JraFFKSUdSNlE2U1MxcXkzT29WT1VheFl6THY0U2s3dndrQUMwUitGREVIeVFZbFVhbVVkTWcyUmdwRUdhSVd1V3IxaGNnRm10QmREV2g3ZFBuWTF0U3VKOC95MXp4NkRvN2ZJYmNFenBBK2E0ODNtRG5vemdld3VmaFdqVCsvUS85WlEreFQ5UWJBT1pQSXhHV3VhSXVrVk8zSWxvZDhJM1NGZFJCTHY5ZXBDNzFLeXpSdVlpMktkOHJ5NVNINit1WnMxUHlZUlpRakdDK3Q4VzRtSE82Z1lFRWVXSkJ1UWhnSHdmV2xhZXlWb3hac0NBQVZKRUllT3hPZDZtNW45OHRCUDdHTmgxT1M0eDRCS2FVN1A0UVQzNVVIZW5meE84WWFQUThmbXlobUJhSVJVZklBTVN2ZTJZRFp5SWNNTTkrN0tNSVVabzJ0eXRvYzdCOGVvZzBNaUkrVkpFdFg0c29FRjFSWkhQZVV3NWlCTjI4OTh2MmVTcGNnVUJhWHFzOUN5VlZtTVJQMEtLUDJ1REt4MUdJcUhjS0ZCOXVQVWRkQS9vT3dNa0tVUWsraFZVVDVPbEVMdjd1a0FBUEE0eE4rZkczVmYxeUVKV0FiVGx5dWtGcThjNXBTRkY1cXVHbUgwVmVpQzVvVEFka1VES3Z6WGhWWUs5c3BRYjNVZ1Z0Qld6N1ZScnlOUVVST3BIZU5xeDlhZHA4YWREWCtRSHJUKytYblN4VVI3SVdGanlNTkZJRWlMWmkxdks1UVVrZlRDUU9qdjh2SHdiUi9MRHF3Z3M5bXdsT3pPY0RLdVBVK0dTb2lnVFdRejRWN0N2SHRaVDI3WUdKVG44RFFFM3IzdjB4aWxvODJ2U3VXSDg0WEU3VEJsTUpFb2R5eDNDRngwVUVkc3VhRHBPSEV3UjZYNlUyU0xseERYSXVZeEhlNXh2NjI4bXU0bDRMSnBYUjhkYmljTEZKQW55Q0FVeDJLb2dDamt1cmU4bXNUZktDbG8wamFlN1hNR05PSk15b0ZYbVlHZUh2eGhNUGMzTEtYLy9VY1p0c3p3dFJrQmNFdURXQysvQWNWZVBOSHVOWWI5MEpIcnRucGg1ZDlhL1lpTkpzY1N3QTFwUVZrdW1TQWtPQWdLdWRzcnl3c0N3Zkg1anNydVpHUTJDd1hKRXQzUU4wU2NLUlVnT1NCQ3FYa1BqZDVSVzJuOFZpamt4anovbWptakhCNmk0eHM5NEU2Nzk5STAyaldYNVd3UDZhTFRaTGt5TjhxNDUxT0RmeUZVZEY5WWsyZXQ5VUpsV1NzRFJMSWVCd0ZyQkEyZTdyRWsybWFLVUNCRW5PUWM2bUhVMXQvZ3gzK1VXVVFXbkpMZVUxbWUvbkFEdy96UGUwd3d0Vm9BaERZdDBoR1hQblJydjFoUHRGS01CeWtqckg3a0J5U0R3WDlQMi9XZkNkQlE5K1J4cHRsR2hvRmdpMUs0NVlOeEpEd05wTmd5MDV2WXUzVUtrMkpRYVNGUzcwK0Y1NzluRE5RenZpK0pPRlRsdDFmWDJGNXk5NEV2NHZobWRQSmRVOFVVRjU2Ymx0emxKREVFdmsySlFrOTM0aHpwTXJGZ1d3ZHUxUkxxSEhCN2h2T2hnaHNqV0ZGY01zNjZaRUtWcVhKUytxWWNVMHk0akwySVQrNlF2N2pvQ3BWbUdzUWtGY1FyblhxOUJiOTdaUS96UCtwaldmWTU0UmNRVlMydUU1YURObVVyVkdLK3E0d0xRcUhuRVViT2puSHFFeGlacUtxOVdRaUtUK2c3QS96bVlIQ2k0YzFTejRNVWhHb0t6U2l4aXoxYUNJUEJXdy9vczR2cUVqbXgzOGx6YnV0OWNWbElzeGNkTUpUTERRK3ZOZ0YyY1ZRaVcxRTQ0d3lWcnI3TUFaOE9KRVpFSzlEZWt5MzJQUkFuSkRUVXVqdGFscmJ0T2VOczhyS09uTjcvNFRqUEwvZmRlbEI4bjA4WXdSNXdmbU42VGpGWUhRSDFjbUZmK1AvNUxVMTI4Q1pEYjNQUStxMlFJazV3aE40eGwvcy9lb29pallmeWtDcm5aSEhHWkluTGhoU2pWbk5ISWdTL203VWV0NlhBTDdvZUl5UFRLeHVnbDJzRWtUQzNnZ0tjTnFZR0E5U3ZlYVlaQ00vWHNQRUtQbWs3QmlRNmprWFBKaE1yREd4Vkc0SW9aSDgrYjBrUWJYR2l0Mkw0L3hZdHh1bTVzcFNPSjdsTDltVFpRNnBxM2JOaTEwZU1mZ0ZWaDc3NU5JRlc0SEp3U1FtaTU0bk11blZTQjhxdjZKc0w3SGlsZ2N0ZHFSNThTTjVad1lCa2dOR1hzYjA1QXJWemVXbHh1Y21BSHNPT3dyczFnMzh6bTRZN2ZPZmducmFhV1kxanZZOFlEODZQZThkZzR4cE5paTg3UnNDZk5WK2NKVmMraktFdnpuZVY1Zzd0RmlxZCtsZHp4STlKemdSS2t0WUV6RUpRSVU5M2UvclJaN1lrVkZtNVV1cjVhMWYzcG83T0VtYkJUc2MrQ1FaOGNnYmIvbUphRXJoa3NyL3JURjBNcjNxeDl5SlJWSEJ6YWNWd0dScEFRaURPdnJkWU4xQXBVOTRyR1lrVFVzdWs1YjE1Wll2QVZxRlRzVlVMaS9HY29mbEljMm01Z2RFTFZOblRmdXY1Zlk5S1NlWHFoUU80S0pOYVZmbHAwQ0VKYWFFZFNLUXJJNXRaT2w1RkE4VXZlNmxTWVd5TVk0REl4a1RiT1JoWHVBdzR6b1RTMjgrN3d2TXhydVBkZnlKbUJCTkhQdCtEYmdKNHovcHJZWUhpTmFMTXNZamtQZE44ajNKZDczQXJFZk92Um52MzYxSVVVMFg1RDc1dlRSdlpkbzMzWERzanRlOU4weUo3K2lIQnF1a1FJY2pIVW9ic2RQN0hOajBVYWNSMHIvTmRlVTlGNFBNc1VLY2t6Tk4rZGhyMVI2d1J2R1VZb1pDRWJaWlJMWEt4QnA3SElUNEVQUktHakIvdW1xTFhhMXl6RWx2QW1WQUJhMDFZN3dGdk4wM2Ywb25FbUhTM2w1d1paRmV6cjVibnN5T01XVGxhMU5kaW1ZNXNVeE15VFliZmc4dzB2cXNEc28zWFAxYndLdzZ3M3VIRGQ1UHBSWnVDSnR0eWk0ZzJGeWI0Ymg1UU42ZkdORTI2ekRGN1Y4QmJwZXJLNkFKQ0xTWm5kaDZMMTlPUTBram4xUGpEMGk4c1BZcGFXOWxVeVJkZElPKzRWQS9LemxPUzJ4M2s5VUtUdElsTTBUSVdtZXFIS0dYUVpocGpvVGI2VlNKN203cjZaaVlQMnVsQVVvZmVWL0o2eCtzckxEQXkyQ2ZFNnFrREZ1OU9NWDBBSXVnN3loQUtOMDRyT3hVNk5tcGtjOUZ4bXUvVS9vR3hHdmIzeFVFTDYwdE1sSE9EaWtqY1I5RDJrKzRwbEc1WnV0d0FIY2kwRU02WHRrVEhQOU5QMlRTR1VFN1E5SGYvU0VEc2V0a25hZXhvWmhDczJLWDFMeU5JS0U0N2pkMkR3MTUreDRRVXV0VUFTbzU5Q1lHMVFBeW9BVVhrV3dtbXkzTGdTUWp5T3ZLV25qaE8veWpPd0FyWGd0NFBrSVVnZDQ1N05ReFpMbU41K0J4NVJoQ0FHdkUxYmxOZjlMek9keGJiaG5VZ2Z1RDM5MXVSRkhjS2RYREY3ZmVqb3gveThtaWZJcTRWVzQyajBHQnFOQUtkK0prMnJCMW9hOTRiT2hxcVVzanhqWnlRaGRXTzhNblR6T2tOaGVpZXU2blYxcW5yZ3JHU2huWTNJMlczb29GNFNnczRjZ3drZ2h2dHpFa0xUbU5OUm83RTdudVRuMkxJcmlGSnlvTmZQdUp0aWN0S0JtNzRGZytkWVBTMlIzTzNmOWxBZWxiVWZjbzZGNU9EL3hkS1VuRTh0V3FOMExVcDlWQUptWVZYZFVDaGJ4MjM4MWtDaStLNDJoRzUydFNQYU1hb1dTb0xQY2Zrb24rc1pYdjdEdEtwZi9HTzdhcUMza1pzRGpva29haHJGZGJWSlNTZWhrNGp5K3RzRHplQnJKSjBrMVZrUnJHN1NoVHZjTmd1cjVucVRUTEE5dlJMQmJNTTlhNlI1NEZ0Z1pQOWFKMU1aMEdCcUVpMnF6Ui8yd2tYQlhwcFhZdi9TcU1RV1dhbTVsSHBMVktxaDN4ZHRjNFdmck9mYldsbU1PNXA5Z0JUSFp1YUcxVGFkZXFRVVpKQmZBS01ENFdSR0NsMDFaeDRTVzE0YzZrdnFKdXExL080N215L3RsVHlLWndpYlBkQTNRMVVGd0I3R2Z4anEwaDN2ckxFbUNrS3Vsc0VBUkN6UnZNVjJSVnBVbFpUV240Y1Boc0hjcTNROElHSUYyKy9nOENFSU4vMU8xcVMvMkpXcXlDNmtIb0w4Y2R2R0VHbmkxSTNDTk1JcXhxaHhJL1V0R3REc2VwYmwrSHI0elh4MzZna3BCbXBoT2xkTFVYTHAzVEtibVVZRWJSWHcvZmRmeFQ3WDdZUFhHQ0hHVG1uTzk4WkxDOTA2Zmkvekd2b04rNlpzbCs3MkpWMGxJWEo0V3dZdWxFUmZHbkFDWGNoa0Yzei9ITWR3elcwTUFFaXptQmwvREo2ZUoyU01PSG1Uc25YbElGRDRlcFRrYnFBQ0dpZ2I1UExFdHdQRVRjYkNRckM5YUtTU1FnSTdEZXd1aWlxM2J0Y0RUWkIzeEI5WWxlbmhpU0FXNjIwcmwzc2ZjY3d3eGFSOHBDV2Rzd0x3dmFxcDhjM01PV3RCc2xPcmVTSkNEcWgvdzBYbm1WMFJVWFpNM2JvUmkwVXhsaHVUeDFlM1NTd09pbTlOczNYV3NoTmI4Lzc3VkhnUWhRVFlSUU1NRllYaWRmMElCKzBtSUpocWNoQTlUeUY3dGRjSDhrUUJUSHNEWS96bFpqK3EwNlFMd0JkbTkxc3IyK3VzZmxlaXB3WUMrcmdiNHROVnA3VU5rYkVqTnR6ZWZsTi9VRTlkbHZtT2x6V1dtZkh2NGVkUGkzMmJmeUNRS1d6SGJVVEV3NU0yVFpsZnpNaTFWUjVsaDBxQ1lqaDNITUlmL2MwcHBKd2I1b1lFTnBBenlxbnlmdmlTV3lBYzc2L1l1VWwvb2FVaysrYzBZc2d1TGo5ZGFQdVVvemhoZ3VjSytQRGlNckI0ODU1Mk83VWg0aHRwNmZ3S2dJa1JCTVFIUTd6MmV5WXovV1AwQm9ZZVhjOGc3aUprclhFNzA1bFo1bXhGU0poT3E1WlNleVJSb21pUm41K3VRemM5ZFdWQjBYb2JURXdOc0VRM2FIZ25JY29BczY2UGplUT09PC94ZW5jOkNpcGhlclZhbHVlPjwveGVuYzpDaXBoZXJEYXRhPjwveGVuYzpFbmNyeXB0ZWREYXRhPjwvc2FtbDI6RW5jcnlwdGVkQXNzZXJ0aW9uPjwvc2FtbDJwOlJlc3BvbnNlPg==
\ No newline at end of file
diff --git a/social/tests/backends/test_saml.py b/social/tests/backends/test_saml.py
new file mode 100644
index 000000000..abe256976
--- /dev/null
+++ b/social/tests/backends/test_saml.py
@@ -0,0 +1,104 @@
+import base64
+import datetime
+from httpretty import HTTPretty
+import json
+from mock import patch
+try:
+ from onelogin.saml2.utils import OneLogin_Saml2_Utils
+except ImportError:
+ pass # Only available for python 2.7 at the moment, so don't worry if this fails
+import os.path
+import re
+import requests
+from social.p3 import urlparse
+from social.utils import parse_qs, url_add_parameters
+from social.tests.models import User
+from social.tests.backends.base import BaseBackendTest
+import sys
+import unittest2
+try:
+ from urllib.parse import urlencode, urlparse, urlunparse, parse_qs
+except ImportError:
+ from urllib import urlencode
+ from urlparse import urlparse, urlunparse, parse_qs
+
+DATA_DIR = os.path.join(os.path.dirname(__file__), 'data')
+
+
+@unittest2.skipUnless(
+ sys.version_info[:2] == (2, 7),
+ "python-saml currently depends on 2.7; 3+ support coming soon")
+@unittest2.skipIf('__pypy__' in sys.builtin_module_names, "dm.xmlsec not compatible with pypy")
+class SAMLTest(BaseBackendTest):
+ backend_path = 'social.backends.saml.SAMLAuth'
+ expected_username = 'myself'
+
+ def extra_settings(self):
+ with open(os.path.join(DATA_DIR, 'saml_config.json'), 'r') as config_file:
+ config_str = config_file.read()
+ return json.loads(config_str)
+
+ def setUp(self):
+ """ Patch the time so that we can replay canned request/response pairs """
+ super(SAMLTest, self).setUp()
+
+ @staticmethod
+ def fixed_time():
+ return OneLogin_Saml2_Utils.parse_SAML_to_time("2015-05-09T03:57:22Z")
+ now_patch = patch.object(OneLogin_Saml2_Utils, 'now', fixed_time)
+ now_patch.start()
+ self.addCleanup(now_patch.stop)
+
+ def install_http_intercepts(self, start_url, return_url):
+ # When we request start_url (https://idp.testshib.org/idp/profile/SAML2/Redirect/SSO...)
+ # we will eventually get a redirect back, with SAML assertion data in the query string.
+ # A pre-recorded correct response is kept in this .txt file:
+ with open(os.path.join(DATA_DIR, 'saml_response.txt'), 'r') as response_file:
+ response_url = response_file.read()
+ HTTPretty.register_uri(HTTPretty.GET, start_url, status=301, location=response_url)
+ HTTPretty.register_uri(HTTPretty.GET, return_url, status=200, body='foobar')
+
+ def do_start(self):
+ # pretend we've started with a URL like /login/saml/?idp=testshib:
+ self.strategy.set_request_data({'idp': 'testshib'}, self.backend)
+ start_url = self.backend.start().url
+ # Modify the start URL to make the SAML request consistent from test to test:
+ start_url = self.modify_start_url(start_url)
+ # If the SAML Identity Provider recognizes the user, we will be redirected back to:
+ return_url = self.backend.redirect_uri
+ self.install_http_intercepts(start_url, return_url)
+ response = requests.get(start_url)
+ self.assertTrue(response.url.startswith(return_url))
+ self.assertEqual(response.text, 'foobar')
+ query_values = dict((k, v[0]) for k, v in parse_qs(urlparse(response.url).query).items())
+ self.assertNotIn(' ', query_values['SAMLResponse'])
+ self.strategy.set_request_data(query_values, self.backend)
+ return self.backend.complete()
+
+ def test_metadata_generation(self):
+ """ Test that we can generate the metadata without error """
+ xml, errors = self.backend.generate_metadata_xml()
+ self.assertEqual(len(errors), 0)
+ self.assertEqual(xml[0], '<')
+
+ def test_login(self):
+ """ Test that we can authenticate with a SAML IdP (TestShib) """
+ user = self.do_login()
+
+ def modify_start_url(self, start_url):
+ """
+ Given a SAML redirect URL, parse it and change the ID to
+ a consistent value, so the request is always identical.
+ """
+ # Parse the SAML Request URL to get the XML being sent to TestShib
+ url_parts = urlparse(start_url)
+ query = dict((k, v[0]) for (k, v) in parse_qs(url_parts.query).iteritems())
+ xml = OneLogin_Saml2_Utils.decode_base64_and_inflate(query['SAMLRequest'])
+ # Modify the XML:
+ xml, changed = re.subn(r'ID="[^"]+"', 'ID="TEST_ID"', xml)
+ self.assertEqual(changed, 1)
+ # Update the URL to use the modified query string:
+ query['SAMLRequest'] = OneLogin_Saml2_Utils.deflate_and_base64_encode(xml)
+ url_parts = list(url_parts)
+ url_parts[4] = urlencode(query)
+ return urlunparse(url_parts)
diff --git a/social/tests/models.py b/social/tests/models.py
index 7dae52f75..80bf6871e 100644
--- a/social/tests/models.py
+++ b/social/tests/models.py
@@ -117,7 +117,7 @@ def get_social_auth(cls, provider, uid):
@classmethod
def get_social_auth_for_user(cls, user, provider=None, id=None):
- return user.social
+ return [usa for usa in user.social if provider in (None, usa.provider) and id in (None, usa.id)]
@classmethod
def create_social_auth(cls, user, uid, provider):
diff --git a/social/tests/requirements.txt b/social/tests/requirements.txt
index 33cef2575..12a8d0dad 100644
--- a/social/tests/requirements.txt
+++ b/social/tests/requirements.txt
@@ -6,3 +6,4 @@ rednose>=0.4.1
requests>=1.1.0
PyJWT>=1.0.0,<2.0.0
unittest2==0.5.1
+git+https://github.com/open-craft/python-saml.git@9602b8133056d8c3caa7c3038761147df3d4b257#egg=python-saml
diff --git a/social/tests/strategy.py b/social/tests/strategy.py
index 9ccd7d04f..d88685f2d 100644
--- a/social/tests/strategy.py
+++ b/social/tests/strategy.py
@@ -50,6 +50,26 @@ def request_host(self):
"""Return current host value"""
return TEST_HOST
+ def request_is_secure(self):
+ """ Is the request using HTTPS? """
+ return False
+
+ def request_path(self):
+ """ path of the current request """
+ return ''
+
+ def request_port(self):
+ """ Port in use for this request """
+ return 80
+
+ def request_get(self):
+ """ Request GET data """
+ return self._request_data.copy()
+
+ def request_post(self):
+ """ Request POST data """
+ return self._request_data.copy()
+
def session_get(self, name, default=None):
"""Return session value for given key"""
return self._session.get(name, default)