Skip to content

Commit 075e232

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 there is no refresh token stored, making refreshing impossible, OR the refresh request fails with a 401, indicating the OP session has terminated, the user is taken through a refresh flow similar to the SessionRefresh middleware. If any error occurs during refresh, the middleware aborts, but does not perform any cleanup on the session. Co-Authored-By: Jason Anderson <[email protected]>
1 parent 799e9f0 commit 075e232

File tree

6 files changed

+348
-63
lines changed

6 files changed

+348
-63
lines changed

docs/installation.rst

Lines changed: 46 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -143,8 +143,8 @@ Next, edit your ``urls.py`` and add the following:
143143

144144
.. code-block:: python
145145
146-
from django.urls import path, include
147-
146+
from django.urls import path
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
@@ -85,7 +85,8 @@ of ``mozilla-django-oidc``.
8585

8686
This is a list of absolute url paths, regular expressions for url paths, or
8787
Django view names. This plus the mozilla-django-oidc urls are exempted from
88-
the session renewal by the ``SessionRefresh`` middleware.
88+
the session renewal by the ``SessionRefresh`` or ``RefreshOIDCAccessToken``
89+
middlewares.
8990

9091
.. py:attribute:: OIDC_CREATE_USER
9192
@@ -174,6 +175,13 @@ of ``mozilla-django-oidc``.
174175
Controls whether the OpenID Connect client stores the OIDC ``id_token`` in the user session.
175176
The session key used to store the data is ``oidc_id_token``.
176177

178+
.. py:attribute:: OIDC_STORE_REFRESH_TOKEN
179+
180+
:default: ``False``
181+
182+
Controls whether the OpenID Connect client stores the OIDC ``refresh_token`` in the user session.
183+
The session key used to store the data is ``oidc_refresh_token``.
184+
177185
.. py:attribute:: OIDC_AUTH_REQUEST_EXTRA_PARAMS
178186
179187
:default: `{}`

mozilla_django_oidc/auth.py

Lines changed: 20 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,17 @@ def default_username_algo(email):
4040
return smart_str(username)
4141

4242

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

@@ -279,12 +290,12 @@ def authenticate(self, request, **kwargs):
279290
token_info = self.get_token(token_payload)
280291
id_token = token_info.get('id_token')
281292
access_token = token_info.get('access_token')
293+
refresh_token = token_info.get('refresh_token')
282294

283295
# Validate the token
284296
payload = self.verify_token(id_token, nonce=nonce)
285-
286297
if payload:
287-
self.store_tokens(access_token, id_token)
298+
self.store_tokens(access_token, id_token, refresh_token)
288299
try:
289300
return self.get_or_create_user(access_token, id_token, payload)
290301
except SuspiciousOperation as exc:
@@ -293,15 +304,14 @@ def authenticate(self, request, **kwargs):
293304

294305
return None
295306

296-
def store_tokens(self, access_token, id_token):
307+
def store_tokens(self, access_token, id_token, refresh_token):
297308
"""Store OIDC tokens."""
298-
session = self.request.session
299-
300-
if self.get_settings('OIDC_STORE_ACCESS_TOKEN', False):
301-
session['oidc_access_token'] = access_token
302-
303-
if self.get_settings('OIDC_STORE_ID_TOKEN', False):
304-
session['oidc_id_token'] = id_token
309+
return store_tokens(
310+
self.request.session,
311+
access_token,
312+
id_token,
313+
refresh_token
314+
)
305315

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

mozilla_django_oidc/middleware.py

Lines changed: 135 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,18 @@
1+
import json
12
import logging
23
import time
34

4-
from django.contrib.auth import BACKEND_SESSION_KEY
5+
from django.contrib import auth
56
from django.http import HttpResponseRedirect, JsonResponse
67
from django.urls import reverse
78
from django.utils.crypto import get_random_string
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
13+
from requests.auth import HTTPBasicAuth
1114

12-
from mozilla_django_oidc.auth import OIDCAuthenticationBackend
15+
from mozilla_django_oidc.auth import OIDCAuthenticationBackend, store_tokens
1316
from mozilla_django_oidc.utils import (absolutify,
1417
add_state_and_nonce_to_session,
1518
import_from_settings)
@@ -95,6 +98,11 @@ def exempt_url_patterns(self):
9598
exempt_patterns.add(url_pattern)
9699
return exempt_patterns
97100

101+
@property
102+
def logout_redirect_url(self):
103+
"""Return the logout url defined in settings."""
104+
return self.get_settings('LOGOUT_REDIRECT_URL', '/')
105+
98106
def is_refreshable_url(self, request):
99107
"""Takes a request and returns whether it triggers a refresh examination
100108
@@ -104,7 +112,7 @@ def is_refreshable_url(self, request):
104112
105113
"""
106114
# Do not attempt to refresh the session if the OIDC backend is not used
107-
backend_session = request.session.get(BACKEND_SESSION_KEY)
115+
backend_session = request.session.get(auth.BACKEND_SESSION_KEY)
108116
is_oidc_enabled = True
109117
if backend_session:
110118
auth_backend = import_string(backend_session)
@@ -118,27 +126,71 @@ def is_refreshable_url(self, request):
118126
not any(pat.match(request.path) for pat in self.exempt_url_patterns)
119127
)
120128

121-
def process_request(self, request):
129+
def is_expired(self, request):
122130
if not self.is_refreshable_url(request):
123131
LOGGER.debug('request is not refreshable')
124-
return
132+
return False
125133

126-
expiration = request.session.get('oidc_id_token_expiration', 0)
134+
expiration = request.session.get('oidc_token_expiration', 0)
127135
now = time.time()
128136
if expiration > now:
129137
# The id_token is still valid, so we don't have to do anything.
130138
LOGGER.debug('id token is still valid (%s > %s)', expiration, now)
139+
return False
140+
141+
return True
142+
143+
def process_request(self, request):
144+
if not self.is_expired(request):
131145
return
132146

133147
LOGGER.debug('id token has expired')
134-
# The id_token has expired, so we have to re-authenticate silently.
148+
return self.finish(request, prompt_reauth=True)
149+
150+
def finish(self, request, prompt_reauth=True):
151+
"""Finish request handling and handle sending downstream responses for XHR.
152+
153+
This function should only be run if the session is determind to
154+
be expired.
155+
156+
Almost all XHR request handling in client-side code struggles
157+
with redirects since redirecting to a page where the user
158+
is supposed to do something is extremely unlikely to work
159+
in an XHR request. Make a special response for these kinds
160+
of requests.
161+
162+
The use of 403 Forbidden is to match the fact that this
163+
middleware doesn't really want the user in if they don't
164+
refresh their session.
165+
"""
166+
default_response = None
167+
xhr_response_json = {'error': 'the authentication session has expired'}
168+
if prompt_reauth:
169+
# The id_token has expired, so we have to re-authenticate silently.
170+
refresh_url = self._prepare_reauthorization(request)
171+
default_response = HttpResponseRedirect(refresh_url)
172+
xhr_response_json['refresh_url'] = refresh_url
173+
174+
if request.headers.get('x-requested-with') == 'XMLHttpRequest':
175+
xhr_response = JsonResponse(xhr_response_json, status=403)
176+
if 'refresh_url' in xhr_response_json:
177+
xhr_response['refresh_url'] = xhr_response_json['refresh_url']
178+
return xhr_response
179+
else:
180+
return default_response
181+
182+
def _prepare_reauthorization(self, request):
183+
# Constructs a new authorization grant request to refresh the session.
184+
# Besides constructing the request, the state and nonce included in the
185+
# request are registered in the current session in preparation for the
186+
# client following through with the authorization flow.
135187
auth_url = self.OIDC_OP_AUTHORIZATION_ENDPOINT
136188
client_id = self.OIDC_RP_CLIENT_ID
137189
state = get_random_string(self.OIDC_STATE_SIZE)
138190

139191
# Build the parameters as if we were doing a real auth handoff, except
140192
# we also include prompt=none.
141-
params = {
193+
auth_params = {
142194
'response_type': 'code',
143195
'client_id': client_id,
144196
'redirect_uri': absolutify(
@@ -152,26 +204,83 @@ def process_request(self, request):
152204

153205
if self.OIDC_USE_NONCE:
154206
nonce = get_random_string(self.OIDC_NONCE_SIZE)
155-
params.update({
207+
auth_params.update({
156208
'nonce': nonce
157209
})
158210

159-
add_state_and_nonce_to_session(request, state, params)
160-
211+
# Register the one-time parameters in the session
212+
add_state_and_nonce_to_session(request, state, auth_params)
161213
request.session['oidc_login_next'] = request.get_full_path()
162214

163-
query = urlencode(params, quote_via=quote)
164-
redirect_url = '{url}?{query}'.format(url=auth_url, query=query)
165-
if request.headers.get('x-requested-with') == 'XMLHttpRequest':
166-
# Almost all XHR request handling in client-side code struggles
167-
# with redirects since redirecting to a page where the user
168-
# is supposed to do something is extremely unlikely to work
169-
# in an XHR request. Make a special response for these kinds
170-
# of requests.
171-
# The use of 403 Forbidden is to match the fact that this
172-
# middleware doesn't really want the user in if they don't
173-
# refresh their session.
174-
response = JsonResponse({'refresh_url': redirect_url}, status=403)
175-
response['refresh_url'] = redirect_url
176-
return response
177-
return HttpResponseRedirect(redirect_url)
215+
query = urlencode(auth_params, quote_via=quote)
216+
return '{auth_url}?{query}'.format(auth_url=auth_url, query=query)
217+
218+
219+
class RefreshOIDCAccessToken(SessionRefresh):
220+
"""
221+
A middleware that will refresh the access token following proper OIDC protocol:
222+
https://auth0.com/docs/tokens/refresh-token/current
223+
"""
224+
def process_request(self, request):
225+
if not self.is_expired(request):
226+
return
227+
228+
token_url = import_from_settings('OIDC_OP_TOKEN_ENDPOINT')
229+
client_id = import_from_settings('OIDC_RP_CLIENT_ID')
230+
client_secret = import_from_settings('OIDC_RP_CLIENT_SECRET')
231+
refresh_token = request.session.get('oidc_refresh_token')
232+
233+
if not refresh_token:
234+
LOGGER.debug('no refresh token stored')
235+
return self.finish(request, prompt_reauth=True)
236+
237+
token_payload = {
238+
'grant_type': 'refresh_token',
239+
'client_id': client_id,
240+
'client_secret': client_secret,
241+
'refresh_token': refresh_token,
242+
}
243+
244+
req_auth = None
245+
if self.get_settings('OIDC_TOKEN_USE_BASIC_AUTH', False):
246+
# When Basic auth is defined, create the Auth Header and remove secret from payload.
247+
user = token_payload.get('client_id')
248+
pw = token_payload.get('client_secret')
249+
250+
req_auth = HTTPBasicAuth(user, pw)
251+
del token_payload['client_secret']
252+
253+
try:
254+
response = requests.post(
255+
token_url,
256+
auth=req_auth,
257+
data=token_payload,
258+
verify=import_from_settings('OIDC_VERIFY_SSL', True)
259+
)
260+
response.raise_for_status()
261+
token_info = response.json()
262+
except requests.exceptions.Timeout:
263+
LOGGER.debug('timed out refreshing access token')
264+
# Don't prompt for reauth as this could be a temporary problem
265+
return self.finish(request, prompt_reauth=False)
266+
except requests.exceptions.HTTPError as exc:
267+
status_code = exc.response.status_code
268+
LOGGER.debug('http error %s when refreshing access token', status_code)
269+
return self.finish(request, prompt_reauth=(status_code == 401))
270+
except json.JSONDecodeError:
271+
LOGGER.debug('malformed response when refreshing access token')
272+
# Don't prompt for reauth as this could be a temporary problem
273+
return self.finish(request, prompt_reauth=False)
274+
except Exception as exc:
275+
LOGGER.debug(
276+
'unknown error occurred when refreshing access token: %s', exc)
277+
# Don't prompt for reauth as this could be a temporary problem
278+
return self.finish(request, prompt_reauth=False)
279+
280+
# Until we can properly validate an ID token on the refresh response
281+
# per the spec[1], we intentionally drop the id_token.
282+
# [1]: https://openid.net/specs/openid-connect-core-1_0.html#RefreshTokenResponse
283+
id_token = None
284+
access_token = token_info.get('access_token')
285+
refresh_token = token_info.get('refresh_token')
286+
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
@@ -49,9 +49,13 @@ def login_success(self):
4949
auth.login(self.request, self.user)
5050

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

5660
return HttpResponseRedirect(self.success_url)
5761

0 commit comments

Comments
 (0)