pennersr / django-allauth

Integrated set of Django applications addressing authentication, registration, account management as well as 3rd party (social) account authentication.
https://allauth.org
MIT License
9.5k stars 3.02k forks source link

OAuth2 Refresh feature? #420

Closed pauricthelodger closed 1 month ago

pauricthelodger commented 11 years ago

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

pennersr commented 11 years ago

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

pauricthelodger commented 11 years ago

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?

phildini commented 8 years ago

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.

pythobot commented 8 years ago

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?

wtfrank commented 8 years ago

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
wli commented 7 years ago

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?

pennersr commented 7 years ago

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.

duebbert commented 7 years ago

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)
WhiteCollarParker commented 4 years ago

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.

toniengelhardt commented 3 years ago

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

lcmartinezdev commented 3 years ago

For my project is relevant too. @WhiteCollarParker

Nicko-13 commented 2 years ago

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!

joshlsullivan commented 1 year ago

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

theptrk commented 1 year ago

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

jjorissen52 commented 1 year ago

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:

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',
  . . .
]
. . .
pennersr commented 4 months ago

I am doubting we need to take this into scope. The rationale is that for authentication purposes this is not needed at all. So we would end up adding code that is not used/needed by most projects at all. And, when taking this into scope, the way of doing that is not clear cut. For example, you might want to refresh tokens on the fly when the user is actively engaging with the site, or you might need to refresh tokens in an offline fashion in a background worker even when the user is not signed in. As the various snippets above have shown, it is not terribly difficult to set this up in the project yourself.

pennersr commented 1 month ago

Moved to https://codeberg.org/allauth/django-allauth/issues/420