Closed pauricthelodger closed 2 months 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...
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?
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.
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?
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
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?
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
.
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)
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.
That sounds great @WhiteCollarParker, for my project it would definitely be relevant!
For my project is relevant too. @WhiteCollarParker
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!
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
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:
access_token
has not expired. If it has, we attempt to refresh it using their provided refresh_token
Below I will list out the relevant parts.
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
],
},
}
. . .
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)
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
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',
. . .
]
. . .
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.
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