Skip to content

Commit c304785

Browse files
Germano GuerriniJason Anderson
authored andcommitted
Add RefreshOIDCAccessToken middleware
The OP can provide a refresh_token to the client on authentication. This can later be used to get a new access_token. Typically refresh_tokens have a longer TTL than access_tokens and represent the total allowed session length. As a bonus, the refresh happens in the background and does not require taking the user to a new location (which also makes it more compatible with e.g., XHR). If any error occurs during refresh, the middleware aborts, but does not perform any cleanup on the session.
1 parent 2bd65e2 commit c304785

File tree

6 files changed

+265
-40
lines changed

6 files changed

+265
-40
lines changed

docs/installation.rst

Lines changed: 45 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -144,7 +144,7 @@ Next, edit your ``urls.py`` and add the following:
144144
.. code-block:: python
145145
146146
from django.urls import path
147-
147+
148148
urlpatterns = [
149149
# ...
150150
path('oidc/', include('mozilla_django_oidc.urls')),
@@ -220,8 +220,50 @@ check to see if the user's id token has expired and if so, redirect to the OIDC
220220
provider's authentication endpoint for a silent re-auth. That will redirect back
221221
to the page the user was going to.
222222

223-
The length of time it takes for an id token to expire is set in
224-
``settings.OIDC_RENEW_ID_TOKEN_EXPIRY_SECONDS`` which defaults to 15 minutes.
223+
The length of time it takes for a token to expire is set in
224+
``settings.OIDC_RENEW_TOKEN_EXPIRY_SECONDS``, which defaults to 15 minutes.
225+
226+
227+
Getting a new access token using the refresh token
228+
--------------------------------------------------
229+
230+
Alternatively, if the OIDC Provider supplies a refresh token during the
231+
authorization phase, it can be stored in the session by setting
232+
``settings.OIDC_STORE_REFRESH_TOKEN`` to `True`.
233+
It will be then used by the
234+
:py:class:`mozilla_django_oidc.middleware.RefreshOIDCAccessToken` middleware.
235+
236+
The middleware will check if the user's access token has expired with the same
237+
logic of :py:class:`mozilla_django_oidc.middleware.SessionRefresh` but, instead
238+
of taking the user through a browser-based authentication flow, it will request
239+
a new access token from the OP in the background.
240+
241+
.. warning::
242+
243+
Using this middleware will effectively cause ID tokens to no longer be stored
244+
in the request session, e.g., ``oidc_id_token`` will no longer be available
245+
to Django. This is due to the fact that secure verification of the ID token
246+
is currently not possible in the refresh flow due to not enough information
247+
about the initial authentication being preserved in the session backend.
248+
249+
If you rely on ID tokens, do not use this middleware. It is only useful if
250+
you are relying instead on access tokens.
251+
252+
To add it to your site, put it in the settings::
253+
254+
MIDDLEWARE_CLASSES = [
255+
# middleware involving session and authentication must come first
256+
# ...
257+
'mozilla_django_oidc.middleware.RefreshOIDCAccessToken',
258+
# ...
259+
]
260+
261+
The length of time it takes for a token to expire is set in
262+
``settings.OIDC_RENEW_TOKEN_EXPIRY_SECONDS``, which defaults to 15 minutes.
263+
264+
.. seealso::
265+
266+
https://openid.net/specs/openid-connect-core-1_0.html#RefreshTokens
225267

226268

227269
Connecting OIDC user identities to Django users

docs/settings.rst

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,8 @@ of ``mozilla-django-oidc``.
7979

8080
This is a list of absolute url paths, regular expressions for url paths, or
8181
Django view names. This plus the mozilla-django-oidc urls are exempted from
82-
the session renewal by the ``SessionRefresh`` middleware.
82+
the session renewal by the ``SessionRefresh`` or ``RefreshOIDCAccessToken``
83+
middlewares.
8384

8485
.. py:attribute:: OIDC_CREATE_USER
8586
@@ -168,6 +169,13 @@ of ``mozilla-django-oidc``.
168169
Controls whether the OpenID Connect client stores the OIDC ``id_token`` in the user session.
169170
The session key used to store the data is ``oidc_id_token``.
170171

172+
.. py:attribute:: OIDC_STORE_REFRESH_TOKEN
173+
174+
:default: ``False``
175+
176+
Controls whether the OpenID Connect client stores the OIDC ``refresh_token`` in the user session.
177+
The session key used to store the data is ``oidc_refresh_token``.
178+
171179
.. py:attribute:: OIDC_AUTH_REQUEST_EXTRA_PARAMS
172180
173181
:default: `{}`

mozilla_django_oidc/auth.py

Lines changed: 20 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,17 @@ def default_username_algo(email):
4242
return smart_text(username)
4343

4444

45+
def store_tokens(session, access_token, id_token, refresh_token):
46+
if import_from_settings('OIDC_STORE_ACCESS_TOKEN', False):
47+
session['oidc_access_token'] = access_token
48+
49+
if import_from_settings('OIDC_STORE_ID_TOKEN', False):
50+
session['oidc_id_token'] = id_token
51+
52+
if import_from_settings('OIDC_STORE_REFRESH_TOKEN', False):
53+
session['oidc_refresh_token'] = refresh_token
54+
55+
4556
class OIDCAuthenticationBackend(ModelBackend):
4657
"""Override Django's authentication."""
4758

@@ -280,12 +291,12 @@ def authenticate(self, request, **kwargs):
280291
token_info = self.get_token(token_payload)
281292
id_token = token_info.get('id_token')
282293
access_token = token_info.get('access_token')
294+
refresh_token = token_info.get('refresh_token')
283295

284296
# Validate the token
285297
payload = self.verify_token(id_token, nonce=nonce)
286-
287298
if payload:
288-
self.store_tokens(access_token, id_token)
299+
self.store_tokens(access_token, id_token, refresh_token)
289300
try:
290301
return self.get_or_create_user(access_token, id_token, payload)
291302
except SuspiciousOperation as exc:
@@ -294,15 +305,14 @@ def authenticate(self, request, **kwargs):
294305

295306
return None
296307

297-
def store_tokens(self, access_token, id_token):
308+
def store_tokens(self, access_token, id_token, refresh_token):
298309
"""Store OIDC tokens."""
299-
session = self.request.session
300-
301-
if self.get_settings('OIDC_STORE_ACCESS_TOKEN', False):
302-
session['oidc_access_token'] = access_token
303-
304-
if self.get_settings('OIDC_STORE_ID_TOKEN', False):
305-
session['oidc_id_token'] = id_token
310+
return store_tokens(
311+
self.request.session,
312+
access_token,
313+
id_token,
314+
refresh_token
315+
)
306316

307317
def get_or_create_user(self, access_token, id_token, payload):
308318
"""Returns a User instance if 1 user is found. Creates a user if not found

mozilla_django_oidc/middleware.py

Lines changed: 70 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import json
12
import logging
23
import time
34

@@ -8,8 +9,9 @@
89
from django.utils.deprecation import MiddlewareMixin
910
from django.utils.functional import cached_property
1011
from django.utils.module_loading import import_string
12+
import requests
1113

12-
from mozilla_django_oidc.auth import OIDCAuthenticationBackend
14+
from mozilla_django_oidc.auth import OIDCAuthenticationBackend, store_tokens
1315
from mozilla_django_oidc.utils import (absolutify,
1416
add_state_and_nonce_to_session,
1517
import_from_settings)
@@ -122,16 +124,24 @@ def is_refreshable_url(self, request):
122124
not any(pat.match(request.path) for pat in self.exempt_url_patterns)
123125
)
124126

125-
def process_request(self, request):
127+
128+
def is_expired(self, request):
126129
if not self.is_refreshable_url(request):
127130
LOGGER.debug('request is not refreshable')
128-
return
131+
return False
129132

130-
expiration = request.session.get('oidc_id_token_expiration', 0)
133+
expiration = request.session.get('oidc_token_expiration', 0)
131134
now = time.time()
132135
if expiration > now:
133136
# The id_token is still valid, so we don't have to do anything.
134137
LOGGER.debug('id token is still valid (%s > %s)', expiration, now)
138+
return False
139+
140+
return True
141+
142+
def process_request(self, request):
143+
144+
if not self.is_expired(request):
135145
return
136146

137147
LOGGER.debug('id token has expired')
@@ -179,3 +189,59 @@ def process_request(self, request):
179189
response['refresh_url'] = redirect_url
180190
return response
181191
return HttpResponseRedirect(redirect_url)
192+
193+
194+
class RefreshOIDCAccessToken(SessionRefresh):
195+
"""
196+
A middleware that will refresh the access token following proper OIDC protocol:
197+
https://auth0.com/docs/tokens/refresh-token/current
198+
"""
199+
def process_request(self, request):
200+
if not self.is_expired(request):
201+
return
202+
203+
token_url = import_from_settings('OIDC_OP_TOKEN_ENDPOINT')
204+
client_id = import_from_settings('OIDC_RP_CLIENT_ID')
205+
client_secret = import_from_settings('OIDC_RP_CLIENT_SECRET')
206+
refresh_token = request.session.get('oidc_refresh_token')
207+
if not refresh_token:
208+
LOGGER.debug('no refresh token stored')
209+
return
210+
211+
token_payload = {
212+
'grant_type': 'refresh_token',
213+
'client_id': client_id,
214+
'client_secret': client_secret,
215+
'refresh_token': refresh_token,
216+
}
217+
218+
try:
219+
response = requests.post(
220+
token_url,
221+
data=token_payload,
222+
verify=import_from_settings('OIDC_VERIFY_SSL', True)
223+
)
224+
response.raise_for_status()
225+
token_info = response.json()
226+
except requests.exceptions.Timeout:
227+
LOGGER.debug('timed out refreshing access token')
228+
return
229+
except requests.exceptions.HTTPError as exc:
230+
LOGGER.debug('http error %s when refreshing access token',
231+
exc.response.status_code)
232+
return
233+
except json.JSONDecodeError:
234+
LOGGER.debug('malformed response when refreshing access token')
235+
return
236+
except Exception as exc:
237+
LOGGER.debug(
238+
'unknown error occurred when refreshing access token: %s', exc)
239+
return
240+
241+
# Until we can properly validate an ID token on the refresh response
242+
# per the spec[1], we intentionally drop the id_token.
243+
# [1]: https://openid.net/specs/openid-connect-core-1_0.html#RefreshTokenResponse
244+
id_token = None
245+
access_token = token_info.get('access_token')
246+
refresh_token = token_info.get('refresh_token')
247+
store_tokens(request.session, access_token, id_token, refresh_token)

mozilla_django_oidc/views.py

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -47,9 +47,13 @@ def login_success(self):
4747
auth.login(self.request, self.user)
4848

4949
# Figure out when this id_token will expire. This is ignored unless you're
50-
# using the RenewIDToken middleware.
51-
expiration_interval = self.get_settings('OIDC_RENEW_ID_TOKEN_EXPIRY_SECONDS', 60 * 15)
52-
self.request.session['oidc_id_token_expiration'] = time.time() + expiration_interval
50+
# using the SessionRefresh or RefreshOIDCAccessToken middlewares.
51+
expiration_interval = self.get_settings(
52+
'OIDC_RENEW_TOKEN_EXPIRY_SECONDS',
53+
# Handle old configuration value
54+
self.get_settings('OIDC_RENEW_ID_TOKEN_EXPIRY_SECONDS', 60 * 15)
55+
)
56+
self.request.session['oidc_token_expiration'] = time.time() + expiration_interval
5357

5458
return HttpResponseRedirect(self.success_url)
5559

0 commit comments

Comments
 (0)