django-allauth icon indicating copy to clipboard operation
django-allauth copied to clipboard

OAuth2 Refresh feature?

Open pauricthelodger opened this issue 10 years ago • 15 comments

Hey there,

Have you considered adding a feature for refreshing access tokens with a given refresh token? Or do you think it's outside the scope of allauth?

Cheers, Padraic

pauricthelodger avatar Oct 17 '13 15:10 pauricthelodger

That's in scope, though I think it is best to integrate https://github.com/requests/requests-oauthlib to do the actual dirty work...

pennersr avatar Oct 18 '13 14:10 pennersr

Cheers for the reply! Yeah, that'd work, was looking at it for the same. I'd be happy to try to work on a pull request if you'd have any suggestions on things to be careful of?

pauricthelodger avatar Oct 18 '13 18:10 pauricthelodger

Hey is there any more thought on this? I've been trying to auth against Google, but refresh tokens aren't saving into the token_secret.

phildini avatar Jan 31 '16 08:01 phildini

For me it's saving the Google refresh_token in the 'token_secret' field. However, the 'expires_at' seems to work just as a reference, and nothing is done with it. So I am thinking on refreshing the token right after it fails when trying to work with it. For this case I'd need to implement a signal-like method to refresh the token, updating it in SocialToken model and try request again.

However, would that fresh new token get overwritten on a second login?

What would be the best way to handle this?

pythobot avatar Feb 03 '16 16:02 pythobot

I hacked together a function to acquire a new access token from the refresh token. It would need some work before it was ready for production, so I'm going to leave it here as a possible starting point for a full implementation or for someone who just wants to get the job done, hack or no hack!

def _refresh_authorise(user, sat):
  old_access_token = sat.token
  refresh_token = sat.token_secret

  app = sat.app
  client_id = app.client_id
  api_key = app.secret
  data={
         "grant_type": "refresh_token",
         "refresh_token": refresh_token}

  auth = requests.auth.HTTPBasicAuth(
      client_id,
      api_key)

  url = "%s/token" % "https://login.eveonline.com/oauth"
  params = None

  resp = requests.request(
    'post',
    url,
    params=params,
    data=data,
    headers=None,
    auth=auth)


  if resp.status_code != 200:
    raise Exception("Unexpected code: %d" % res.status_code)


  res = resp.json()
  access_token = res['access_token']
  expires_in = res['expires_in']

  sat.token = access_token
  sat.expires_at = timezone.now() + timedelta(seconds=expires_in)
  sat.save()
  return

wtfrank avatar Aug 29 '16 10:08 wtfrank

I think there's a couple different ways to do this, and they each handle the cases a little differently. There's two main things to determine whether allauth should support: known expired tokens (expires_at in SocialApplicationToken), and unknown expired tokens (when you attempt to make a query, and the token fails, thus you need to refresh it).

If we only need to support the known expired tokens, then we should just create a .ensure_valid_token() function on OAuth2Adapter instances. The standard implementation would probably look something like:

def ensure_valid_token(token, force=False):
  if token.expires_at > timezone.now() and not force:
    return  # Already valid, no need to do anything.

  # Otherwise, we need to refresh
  client = # Construct a valid OAuth2Client using a new `refresh_token_url` field on OAuth2Adapter
  token.token = client.get_refresh_token(token.token_secret)
  token.save()

Otherwise, if we need to handle errors on the fly, we would have to be able to construct requests-oauthlib.OAuth2Session objects and use the token_updater callback to update with the latest token as you go:

def get_oauth2_session(self, social_application_token):
    def token_updater(token):
        social_application_token.token = token
        social_application_token.save()

    extra = {
        'client_id': client_id,
        'client_secret': client_secret,
    }
    return OAuth2Session(client_id,
                           token={'token': social_application_token.token,
                                  'token_type': 'Bearer',
                                  'refresh_token': social_application_token.token_secret},
                           auto_refresh_kwargs=extra,
                           auto_refresh_url=self.refresh_url,
                           token_updater=token_updater)

You can see some more details in the requests-oauth docs

@pennersr Any thoughts?

wli avatar Dec 05 '16 09:12 wli

The second form (handling errors on the fly) is the more robust one -- in edge case situations you may still bump into expired tokens when only checking against expires_at.

pennersr avatar Dec 05 '16 13:12 pennersr

As I've implemented this just now for our project and there are details that are different from the code above (e.g. expires_in must be set on the token), here is the code that works for us (in our case using Azure AD):

def get_oauth2_session(request):
    """ Create OAuth2 session which autoupdates the access token if it has expired """

    # This needs to be amended to whatever your refresh_token_url is.
    refresh_token_url = AzureOAuth2Adapter.refresh_token_url
    
    social_token = SocialToken.objects.get(account__user=request.user)

    def token_updater(token):
        social_token.token = token['access_token']
        social_token.token_secret = token['refresh_token']
        social_token.expires_at = timezone.now() + timedelta(seconds=int(token['expires_in']))
        social_token.save()

    client_id = social_token.app.client_id
    client_secret = social_token.app.secret

    extra = {
        'client_id': client_id,
        'client_secret': client_secret
    }

    expires_in = (social_token.expires_at - timezone.now()).total_seconds()
    token = {
        'access_token': social_token.token,
        'refresh_token': social_token.token_secret,
        'token_type': 'Bearer',
        'expires_in': expires_in  # Important otherwise the token update doesn't get triggered.
    }

    return OAuth2Session(client_id, token=token, auto_refresh_kwargs=extra, 
                         auto_refresh_url=refresh_token_url, token_updater=token_updater)

duebbert avatar May 16 '17 14:05 duebbert

Hi,

I similar issue on my project and @duebbert solution was very helpful. ( Thank you )

For quite some time I was considering whether to make a contribution, Is this issue still relevant ? If yes, I would like to try it.

WhiteCollarParker avatar Sep 23 '20 14:09 WhiteCollarParker

That sounds great @WhiteCollarParker, for my project it would definitely be relevant!

toniengelhardt avatar Jan 15 '21 22:01 toniengelhardt

For my project is relevant too. @WhiteCollarParker

lcmartinezdev avatar Jul 24 '21 14:07 lcmartinezdev

Hello everyone! Could anyone advice, please, where code from @duebbert solution should live? Seems like some kind of custom social account adapter is needed for this, but I failed to find method get_oauth2_session to properly override this. Many thanks in advance!

Nicko-13 avatar Feb 17 '22 09:02 Nicko-13

Hello everyone! Could anyone advice, please, where code from @duebbert solution should live? Seems like some kind of custom social account adapter is needed for this, but I failed to find method get_oauth2_session to properly override this. Many thanks in advance!

Probably setup a custom management command and run it as a cron job - https://docs.djangoproject.com/en/dev/howto/custom-management-commands/#howto-custom-management-commands

joshlsullivan avatar Oct 28 '22 04:10 joshlsullivan

I really need this functionality but I'm struggling to find where token_updater would go in the codebase.

Does anyone now how to refresh the tokens manually (before PR is made to this repo)?

I used the Creds.refresh (code) from here (link) but I'm getting a RefreshError

theptrk avatar Feb 02 '23 16:02 theptrk

Thanks to @duebbert for sharing their solution. We were able to adapt your example to our use-case. It uses the Microsoft adapter but the idea should be very similar for other oauth2 based providers. Unfortunately it is not generic enough to warrant a PR, but I think there is enough here to help many others get started.

To summarize the functionality:

  • We make no use of the Graph API once authenticated, we only use it to delegate access control to that system
  • We implement a middleware to determine that the user's Graph authentication should still be active:
    • On each request, we ensure access_token has not expired. If it has, we attempt to refresh it using their provided refresh_token
    • If the refresh fails, the user is logged out

Below I will list out the relevant parts.

Refresh Tokens

Azure Graph doesn't provide a refresh_token unless you authenticate your users with the offline_access scope:

# django_project/django_project/settings.py
. . .
SOCIALACCOUNT_PROVIDERS = {
    'microsoft': {
        'tenant': required(ENV, "AZURE_TENANT_ID"),
        "APP": {
            'client_id': required(ENV, "AZURE_CLIENT_ID"),
            'secret': required(ENV, "AZURE_SECRET")
        },
        # modify scopes requested during login
        'SCOPE': [
            "User.Read",  # access to user's account information
            "offline_access"  # provide a refresh_token when the user logs in
        ],
    },
}
. . .

Token Persistence

To use refresh tokens for users, you need to store them somewhere. We use the provided SocialToken model:

# django_project/my_app/apps.py
from typing import Any

from django.apps import AppConfig, apps
from allauth.socialaccount import signals


def save_social_token(sociallogin=None, **kwargs):
    """
    Ensure there is exactly one stored SocialToken for the user
    """
    SocialToken = apps.get_model("socialaccount", "SocialToken")
    SocialAccount = apps.get_model("socialaccount", "SocialAccount")
    SocialApp = apps.get_model("socialaccount", "SocialApp")
    token, user = get_multi(sociallogin, "token", "user")
    if not all((token, user)):
        return

    # our adapter enforces the existence of a SocialAccount associated with the user at this point
    account = SocialAccount.objects.get(user_id=user.id)

    try:
        # replace the old token with the new one
        old_token = SocialToken.objects.get(account_id=account.id)
        token.id = old_token.id
    except SocialToken.DoesNotExist:
        pass
    except SocialToken.MultipleObjectsReturned:
        SocialToken.objects.filter(account_id=account.id).delete()

    app = token.app
    app.secret = ''
    app.key = ''
    # get or create the corresponding app from the database, assuming you only have one app per Azure Application,
    # a safe assumption as long as you aren't using multiple sites functionality
    try:
        app = SocialApp.objects.get(provider=app.provider, client_id=app.client_id)
    except SocialApp.DoesNotExist:
        app.name = app.provider
        app.save()
    # cannot save a token without specifying an app
    token.app_id = app.id
    token.account_id = account.id
    token.save()


class MyAppAppConfig(AppConfig):
    name = "my_app"
    verbose_name = "My App"

    def ready(self):
        # triggers anytime someone logs in with a social account
        signals.social_account_updated.connect(save_social_token)


def get_multi(value: Any, *items):
    return (getattr(value, item, None) for item in items)

Middleware

This ensures the access_token has not expired and refreshes it if possible. If not, the user is logged out.

# django_project/my_app/middleware.py

import logging

from django.contrib.auth import logout
from django.core.exceptions import ImproperlyConfigured
from django.utils import timezone
from django.conf import settings

from requests_oauthlib import OAuth2Session
from allauth.socialaccount.models import SocialToken
from allauth.socialaccount.providers.microsoft.views import MicrosoftGraphOAuth2Adapter

def oauth_session_enforcement(get_response):
    """
    For any user that is authenticated via Microsoft Graph, we check that their token
    has not expired. If it has, we try to refresh it. If we can't refresh it, we log
    them out.
    """

    microsoft_provider = settings.SOCIALACCOUNT_PROVIDERS["microsoft"]
    logger = logging.getLogger(f"{__name__}.{oauth_session_enforcement.__name__}")

    def middleware(request):
        if not hasattr(request, "user"):
            raise ImproperlyConfigured(
                "oauth_session_enforcement must be included in middlewares after "
                "django.contrib.auth.middleware.AuthenticationMiddleware or equivalent"
            )
        user = request.user
        try:
            social_token = SocialToken.objects.get(account__user_id=user.id)
        except SocialToken.DoesNotExist:
           # means our user was logged in via username and password so they can stay authenticated
            return get_response(request)
        if social_token.expires_at > timezone.now():
            return get_response(request)
        adapter = MicrosoftGraphOAuth2Adapter(request)
        try:
            logger.debug("refreshing access token for %s", user)
            new_social_token = adapter.parse_token(
                OAuth2Session(
                    client_id=microsoft_provider["APP"]["client_id"],
                    token=dict(
                        access_token=social_token.token,
                        refresh_token=social_token.token_secret,
                        token_type="Bearer",
                    )
                ).refresh_token(
                    token_url=adapter.access_token_url,
                    client_id=microsoft_provider["APP"]["client_id"],
                    client_secret=microsoft_provider["APP"]["secret"],
                )
            )
            new_social_token.id = social_token.id  # replace the existing token instead of creating a new one
            new_social_token.app_id = social_token.app_id
            new_social_token.account_id = social_token.account_id
            new_social_token.save()
        except:  # noqa
            logger.exception("Failed to refresh expired access_token")
            logout(request)
        return get_response(request)
    return middleware

Install Middleware

It needs to come after django.contrib.auth.middleware.AuthenticationMiddleware

# django_project/django_project/settings.py
. . .
MIDDLEWARE = [
  . . .,
  'django.contrib.auth.middleware.AuthenticationMiddleware',
  'my_app.middleware.oauth_session_enforcement',
  . . .
]
. . .

jjorissen52 avatar Aug 18 '23 18:08 jjorissen52