Search code examples
pythondjangooauth-2.0python-social-auth

Decide when to refresh OAUTH2 token with Python Social Auth


I believe this is mostly a question about best practices.

I have an OAUTH2 provider that issues access tokens (valid for 10 hours) as long as refresh tokens.

I found here that it is pretty easy to refresh the access token but I cannot understand how to decide when it is time to refresh.

The easy answer is probably "when it does not work any more", meaning when I get a HTTP 401 from the backend. The problem with this solution is that it is not that efficient, plus I can only assume I got a 401 because the token has expired.

I my django app I found that the user social auth has an Extra data field containing something like this:

{ "scope": "read write", "expires": 36000, "refresh_token": "xxxxxxxxxxxxx", "access_token": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", "token_type": "Bearer" }

but I am not sure how to use the expires field.

So my question is: how do I know if an access token has expired and I need to refresh it?

EDIT: I just found this comment that seems relevant, but I cannot understand how to plug this new function in the pipeline in order to work during the token refresh.


Solution

  • I eventually figured this out. The reason I was initially confused was because there are actually two cases:

    1. When the user comes from a login, so when basically the pipeline get executed.
    2. When the token is refreshed calling the user social auth method refresh_token

    To solve the first case

    I created a new function for the pipeline:

    def set_last_update(details, *args, **kwargs):  # pylint: disable=unused-argument
        """
        Pipeline function to add extra information about when the social auth
        profile has been updated.
        Args:
            details (dict): dictionary of informations about the user
        Returns:
            dict: updated details dictionary
        """
        details['updated_at'] = datetime.utcnow().timestamp()
        return details
    

    in the settings I added it in the pipeline right before the load_extra_data

    SOCIAL_AUTH_PIPELINE = (
        'social.pipeline.social_auth.social_details',
        'social.pipeline.social_auth.social_uid',
        'social.pipeline.social_auth.auth_allowed',
        'social.pipeline.social_auth.social_user',
        'social.pipeline.user.get_username',
        'social.pipeline.user.create_user',
        'social.pipeline.social_auth.associate_user',
        # the following custom pipeline func goes before load_extra_data
        'backends.pipeline_api.set_last_update',
        'social.pipeline.social_auth.load_extra_data',
        'social.pipeline.user.user_details',
        'backends.pipeline_api.update_profile_from_edx',
        'backends.pipeline_api.update_from_linkedin',
    )
    

    and, still in the settings I added the new field in the extra data.

    SOCIAL_AUTH_EDXORG_EXTRA_DATA = ['updated_at']
    

    For the second case:

    I overwrote the refresh_token method of my backend to add the extra field.

    def refresh_token(self, token, *args, **kwargs):
        """
        Overridden method to add extra info during refresh token.
        Args:
            token (str): valid refresh token
        Returns:
            dict of information about the user
        """
        response = super(EdxOrgOAuth2, self).refresh_token(token, *args, **kwargs)
        response['updated_at'] = datetime.utcnow().timestamp()
        return response
    

    Still in the backend class, I added an extra field to extract the expires_in field coming from the server.

    EXTRA_DATA = [
        ('refresh_token', 'refresh_token', True),
        ('expires_in', 'expires_in'),
        ('token_type', 'token_type', True),
        ('scope', 'scope'),
    ]
    

    At this point I have the timestamp when the access token has been created (updated_at) and the amount of seconds it will be valid (expires_in).

    NOTE: the updated_at is an approximation, because it is created on the client and not on the provider server.

    Now the only thing missing is a function to check if it is time to refresh the access token.

    def _send_refresh_request(user_social):
        """
        Private function that refresh an user access token
        """
        strategy = load_strategy()
        try:
            user_social.refresh_token(strategy)
        except HTTPError as exc:
            if exc.response.status_code in (400, 401,):
                raise InvalidCredentialStored(
                    message='Received a {} status code from the OAUTH server'.format(
                        exc.response.status_code),
                    http_status_code=exc.response.status_code
                )
            raise
    
    
    def refresh_user_token(user_social):
        """
        Utility function to refresh the access token if is (almost) expired
        Args:
            user_social (UserSocialAuth): a user social auth instance
        """
        try:
            last_update = datetime.fromtimestamp(user_social.extra_data.get('updated_at'))
            expires_in = timedelta(seconds=user_social.extra_data.get('expires_in'))
        except TypeError:
            _send_refresh_request(user_social)
            return
        # small error margin of 5 minutes to be safe
        error_margin = timedelta(minutes=5)
        if datetime.utcnow() - last_update >= expires_in - error_margin:
            _send_refresh_request(user_social)
    

    I hope this can be helpful for other people.