Skip to content

Commit fbb7235

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).
1 parent 2bd65e2 commit fbb7235

File tree

6 files changed

+247
-40
lines changed

6 files changed

+247
-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: 52 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,9 @@
88
from django.utils.deprecation import MiddlewareMixin
99
from django.utils.functional import cached_property
1010
from django.utils.module_loading import import_string
11+
import requests
1112

12-
from mozilla_django_oidc.auth import OIDCAuthenticationBackend
13+
from mozilla_django_oidc.auth import OIDCAuthenticationBackend, store_tokens
1314
from mozilla_django_oidc.utils import (absolutify,
1415
add_state_and_nonce_to_session,
1516
import_from_settings)
@@ -122,16 +123,24 @@ def is_refreshable_url(self, request):
122123
not any(pat.match(request.path) for pat in self.exempt_url_patterns)
123124
)
124125

125-
def process_request(self, request):
126+
127+
def is_expired(self, request):
126128
if not self.is_refreshable_url(request):
127129
LOGGER.debug('request is not refreshable')
128-
return
130+
return False
129131

130-
expiration = request.session.get('oidc_id_token_expiration', 0)
132+
expiration = request.session.get('oidc_token_expiration', 0)
131133
now = time.time()
132134
if expiration > now:
133135
# The id_token is still valid, so we don't have to do anything.
134136
LOGGER.debug('id token is still valid (%s > %s)', expiration, now)
137+
return False
138+
139+
return True
140+
141+
def process_request(self, request):
142+
143+
if not self.is_expired(request):
135144
return
136145

137146
LOGGER.debug('id token has expired')
@@ -179,3 +188,42 @@ def process_request(self, request):
179188
response['refresh_url'] = redirect_url
180189
return response
181190
return HttpResponseRedirect(redirect_url)
191+
192+
193+
class RefreshOIDCAccessToken(SessionRefresh):
194+
"""
195+
A middleware that will refresh the access token following proper OIDC protocol:
196+
https://auth0.com/docs/tokens/refresh-token/current
197+
"""
198+
def process_request(self, request):
199+
if not self.is_expired(request):
200+
return
201+
202+
token_url = import_from_settings('OIDC_OP_TOKEN_ENDPOINT')
203+
client_id = import_from_settings('OIDC_RP_CLIENT_ID')
204+
client_secret = import_from_settings('OIDC_RP_CLIENT_SECRET')
205+
refresh_token = request.session.get('oidc_refresh_token')
206+
if not refresh_token:
207+
LOGGER.debug('no refresh token stored')
208+
return
209+
210+
token_payload = {
211+
'grant_type': 'refresh_token',
212+
'client_id': client_id,
213+
'client_secret': client_secret,
214+
'refresh_token': refresh_token,
215+
}
216+
217+
response = requests.post(token_url,
218+
data=token_payload,
219+
verify=import_from_settings('OIDC_VERIFY_SSL', True))
220+
response.raise_for_status()
221+
222+
token_info = response.json()
223+
# Until we can properly validate an ID token on the refresh response
224+
# per the spec[1], we intentionally drop the id_token.
225+
# [1]: https://openid.net/specs/openid-connect-core-1_0.html#RefreshTokenResponse
226+
id_token = None
227+
access_token = token_info.get('access_token')
228+
refresh_token = token_info.get('refresh_token')
229+
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)