3

I have a django application that uses django-social-auth and allows for three types of SSO authentication, Google, Office 365, and SAML2.0. In the case of SAML2.0, the application allows the end user to specify their own IdP, and to enable that, we have a custom Database SAML Auth class, that allows us to store the users IdP information in the database, and log the user in. This works as expected, and users can sign in with SAML, Google, or Office 365, no problem.

The challenge is when I need to be redirected to a specific URL once the login has completed. This is working as expected for Google and Office 365, but not for SAML login.

As an example, I have a mobile application that authenticates via OAUTH to the django web application. When that mobile application starts its oauth flow, it goes to the authorization URL, and the gets forwarded to login. The path looks something like this:

  1. https://myapp.com/oauth/authorize/?client_id=something
  2. https://myapp.com/login/?next=/oauth/authorize/?client_id=something
  3. (after selecting sign in with SAML login method) https://myapp.com/login/subdomain/?next=/oauth/authorize/?client_id=something
  4. Redirect to IdP
  5. Redirect back to assertion consumer url https://myapp.com/login/sso/saml/complete/
  6. Redirect to account page https://myapp.com/manage/ (not https://myapp.com/oauth/authorize/?client_id=something as expected)

The frustration of course is that this breaks the oauth flow of my mobile application, as the user is logged into the web application, but never authorizes the mobile application.

According to python social auth documentation the value of ?next= should be used if present to redirect the user after a successful authentication.

I have done a lot of debugging, and I can see in the social-core do_auth method that the next parameter is indeed added to the session as expected.

But, when I debug and look at the session returned in the social-core do_complete method after the IdP posts the user back to my application, the session is empty and doesn't contain any data, including not having my next parameter.

At one point, all of this worked as expected. However, it has stopped working and I can not find any code change that seems to point to why this is the case. My question to the greater community is, have I missed something that might resolve my issue and set me back on the path where SAML login redirects as expected.

Some further details that may be helpful:

  • Django version 2.2.24
  • Python 3.6
  • social-auth-app-django versions 3.0.0 and 5.0.0 tested
  • social-auth-core versions 3.3.3 and 4.1.0 tested

My middlewares (note many of these are custom middlewares as part of our application):

MIDDLEWARE = [
    'instana.instrumentation.django.middleware.InstanaMiddleware',
    'eb.middleware.HealthCheckMiddleware',
    'eb.middleware.OriginalXForwardedProtoMiddleware',
    'eb.middleware.DebugMiddleware',
    'eb.middleware.ContentSecurityPolicy',
    'django_cookies_samesite.middleware.CookiesSameSite',
    'common.middleware.RequestIDMiddleware',
    'common.middleware.APIErrorHandler',
    'corsheaders.middleware.CorsMiddleware',
    'django.contrib.sessions.middleware.SessionMiddleware',
    'django.middleware.locale.LocaleMiddleware',
    'django.middleware.common.CommonMiddleware',
    'django.middleware.csrf.CsrfViewMiddleware',
    'django.middleware.clickjacking.XFrameOptionsMiddleware',
    'django.contrib.auth.middleware.AuthenticationMiddleware',
    'accounts.middleware.TwoFactorMiddleware',
    'accounts.middleware.PasswordChangeMiddleware',
    'accounts.middleware.VerifyActiveMiddleware',
    'django.contrib.messages.middleware.MessageMiddleware',
    'django.middleware.gzip.GZipMiddleware',
    'billing.middleware.CartMiddleware',
    'accounts.middleware.SentryContextMiddleware',
    'accounts.middleware.SocialAuthExceptionMiddleware',
    'accounts.middleware.RealIPMethodMiddleware',
    'common.sqla.middleware.SQLAlchemyMiddleware',
    'django.middleware.security.SecurityMiddleware',
]

Already attempted troubleshooting steps:

  • Remove / rearrange middlewares
  • Change settings in the django application both for session cookies and SOCIAL_AUTH security settings
  • Run several PDB sessions to see the session setting and getting noted above

Thank you for any help in advance.

2 Answers 2

2

I also stumbled upon this today and this might be an issue with SameSite cookies. As you rightly noted, the next parameter is correctly set within the user's session. To remember the session, the session id is stored within a cookie before the redirect to the SAML-IDP happens. Once the authentication happened on the SAML-Provider, the latter will redirect back to django but since the default setting for SameSite cookies is Lax, the cookie will not be sent along with the (redirect) request as the domain of the SAML provider is most likely not the "same site" as the django site.

In essence this means, the next parameter is correctly stored within a session, but once the user comes to django after authenticating via the IDP, django cannot relate back the session.

To mitigate this, we'd either need to set the SameSite cookie policy to be None, thus allowing cookies to be sent also with requests from other domains / sites than the one the cookie was issued for or find another way to keep the state (the path to redirect to after login) between the two requests. SAML itself offers functionality to do exactly that, called RelayState:

This RelayState parameter is meant to be an opaque identifier that is passed back without any modification or inspection

Sounds great in theory, but python-social-core / django-social-auth currently (ab-)use RelayState to only pass along the idp's name as they attempt to support multiple SAML-IDPs on one site. So the built-in SAML provider would need to be adapted to pass along also the next parameter alongside the name of the idp to make this work.

1
  • In our implementation relay state is used to identify the integration in the database. We could potentially look at adding it to the state and have a function that parses the state to get the two values out. That wolud be similar to answer from Thihara. Commented Feb 20 at 20:53
1

We ended up doing the following to solve the same problem.

We needed a few session parameters stored, these parameters were added to the idp_name which is just a proxy for RelayState.

When auth flow completed the overridden auth_complete method will decode the custom encoded RelayState, store the parameters in the session and replace the parameter with just the idp_name as expected by social auth and other pipeline steps downstream.

A sample of the code is below.

from social_core.backends.saml import SAMLAuth 


class MySAMLAuth(SAMLAuth):
    name = 'myauth'


    def auth_url(self):
        idp_name = self.name
        auth = self._create_saml_auth(idp=self.get_idp(idp_name))
        
        """
        This is where the extra params are added in a custom format 
        """
        idp_name_with_params = self._store_session_variables_in_idp_name()
        return auth.login(return_to=idp_name_with_params)


    def auth_complete(self, *args, **kwargs):
        try:
            """
            This is where we get the RelayState parameter, which has our data. 
            And then we parse it, extracts the idp name, and the parameters.

            We then modify the data in the POST and GET parameters
            so downstream things don't break.
            """
            relay_state = self.strategy.request_data()['RelayState']
            idp_name, _ = self._decode_relay_state(relay_state)
            if idp_name:
                self._replace_relay_state(idp_name)

            authenticated_account = super().auth_complete(*args, **kwargs)
        except Exception:
            logger.exception('Something went wrong')
            return None

    def _replace_relay_state(self, custom_relay_state_value):
        if not self.strategy.request:
            return

        is_post = self.strategy.request.method == 'POST'
        data_source = self.strategy.request.POST if is_post else self.strategy.request.GET

        # Make a mutable copy of the data
        data = data_source.copy()

        # Modify the 'RelayState' parameter
        if 'RelayState' in data:
            data['RelayState'] = custom_relay_state_value

        data._mutable = False

        # Set the modified data back to the request object
        if is_post:
            self.strategy.request.POST = data
        else:
            self.strategy.request.GET = data

    def _store_session_variables_in_idp_name(self):
        params_to_append = [SSO_JUST_TO_FIND_USER, ONBOARDING_STATUS]

        session_values = {param: self.strategy.session_get(param) for param in params_to_append if
                          self.strategy.session_get(param)}

        # Construct the custom formatted string
        if session_values:
            formatted_params = ','.join([f"{key}={value}" for key, value in session_values.items()])
            return f"{self.name}|{formatted_params}"
        else:
            return self.name

    def _decode_relay_state(self, relay_state):
        """
        Decodes the custom-formatted RelayState parameter and sets the
        values back into the session.
        """
        try:
            idp_name, param_str = relay_state.split('|', 1)
            params = dict(item.split('=', 1) for item in param_str.split(','))

            # Set each parameter back into the session
            for key, value in params.items():
                self.strategy.session_set(key, value)

            return idp_name, params
        except ValueError:
            # Handle the error or return a default value
            return None, {}

Not the answer you're looking for? Browse other questions tagged or ask your own question.