Skip to content

Commit

Permalink
Add RefreshOIDCAccessToken middleware
Browse files Browse the repository at this point in the history
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]>
  • Loading branch information
2 people authored and diurnalist committed Oct 21, 2021
1 parent 799e9f0 commit 7bf3b99
Show file tree
Hide file tree
Showing 6 changed files with 348 additions and 63 deletions.
50 changes: 46 additions & 4 deletions docs/installation.rst
Original file line number Diff line number Diff line change
Expand Up @@ -143,8 +143,8 @@ Next, edit your ``urls.py`` and add the following:

.. code-block:: python
from django.urls import path, include
from django.urls import path
urlpatterns = [
# ...
path('oidc/', include('mozilla_django_oidc.urls')),
Expand Down Expand Up @@ -220,8 +220,50 @@ check to see if the user's id token has expired and if so, redirect to the OIDC
provider's authentication endpoint for a silent re-auth. That will redirect back
to the page the user was going to.

The length of time it takes for an id token to expire is set in
``settings.OIDC_RENEW_ID_TOKEN_EXPIRY_SECONDS`` which defaults to 15 minutes.
The length of time it takes for a token to expire is set in
``settings.OIDC_RENEW_TOKEN_EXPIRY_SECONDS``, which defaults to 15 minutes.


Getting a new access token using the refresh token
--------------------------------------------------

Alternatively, if the OIDC Provider supplies a refresh token during the
authorization phase, it can be stored in the session by setting
``settings.OIDC_STORE_REFRESH_TOKEN`` to `True`.
It will be then used by the
:py:class:`mozilla_django_oidc.middleware.RefreshOIDCAccessToken` middleware.

The middleware will check if the user's access token has expired with the same
logic of :py:class:`mozilla_django_oidc.middleware.SessionRefresh` but, instead
of taking the user through a browser-based authentication flow, it will request
a new access token from the OP in the background.

.. warning::

Using this middleware will effectively cause ID tokens to no longer be stored
in the request session, e.g., ``oidc_id_token`` will no longer be available
to Django. This is due to the fact that secure verification of the ID token
is currently not possible in the refresh flow due to not enough information
about the initial authentication being preserved in the session backend.

If you rely on ID tokens, do not use this middleware. It is only useful if
you are relying instead on access tokens.

To add it to your site, put it in the settings::

MIDDLEWARE_CLASSES = [
# middleware involving session and authentication must come first
# ...
'mozilla_django_oidc.middleware.RefreshOIDCAccessToken',
# ...
]

The length of time it takes for a token to expire is set in
``settings.OIDC_RENEW_TOKEN_EXPIRY_SECONDS``, which defaults to 15 minutes.

.. seealso::

https://openid.net/specs/openid-connect-core-1_0.html#RefreshTokens


Connecting OIDC user identities to Django users
Expand Down
10 changes: 9 additions & 1 deletion docs/settings.rst
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,8 @@ of ``mozilla-django-oidc``.

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

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

.. py:attribute:: OIDC_STORE_REFRESH_TOKEN
:default: ``False``

Controls whether the OpenID Connect client stores the OIDC ``refresh_token`` in the user session.
The session key used to store the data is ``oidc_refresh_token``.

.. py:attribute:: OIDC_AUTH_REQUEST_EXTRA_PARAMS
:default: `{}`
Expand Down
30 changes: 20 additions & 10 deletions mozilla_django_oidc/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,17 @@ def default_username_algo(email):
return smart_str(username)


def store_tokens(session, access_token, id_token, refresh_token):
if import_from_settings('OIDC_STORE_ACCESS_TOKEN', False):
session['oidc_access_token'] = access_token

if import_from_settings('OIDC_STORE_ID_TOKEN', False):
session['oidc_id_token'] = id_token

if import_from_settings('OIDC_STORE_REFRESH_TOKEN', False):
session['oidc_refresh_token'] = refresh_token


class OIDCAuthenticationBackend(ModelBackend):
"""Override Django's authentication."""

Expand Down Expand Up @@ -279,12 +290,12 @@ def authenticate(self, request, **kwargs):
token_info = self.get_token(token_payload)
id_token = token_info.get('id_token')
access_token = token_info.get('access_token')
refresh_token = token_info.get('refresh_token')

# Validate the token
payload = self.verify_token(id_token, nonce=nonce)

if payload:
self.store_tokens(access_token, id_token)
self.store_tokens(access_token, id_token, refresh_token)
try:
return self.get_or_create_user(access_token, id_token, payload)
except SuspiciousOperation as exc:
Expand All @@ -293,15 +304,14 @@ def authenticate(self, request, **kwargs):

return None

def store_tokens(self, access_token, id_token):
def store_tokens(self, access_token, id_token, refresh_token):
"""Store OIDC tokens."""
session = self.request.session

if self.get_settings('OIDC_STORE_ACCESS_TOKEN', False):
session['oidc_access_token'] = access_token

if self.get_settings('OIDC_STORE_ID_TOKEN', False):
session['oidc_id_token'] = id_token
return store_tokens(
self.request.session,
access_token,
id_token,
refresh_token
)

def get_or_create_user(self, access_token, id_token, payload):
"""Returns a User instance if 1 user is found. Creates a user if not found
Expand Down
161 changes: 135 additions & 26 deletions mozilla_django_oidc/middleware.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,18 @@
import json
import logging
import time

from django.contrib.auth import BACKEND_SESSION_KEY
from django.contrib import auth
from django.http import HttpResponseRedirect, JsonResponse
from django.urls import reverse
from django.utils.crypto import get_random_string
from django.utils.deprecation import MiddlewareMixin
from django.utils.functional import cached_property
from django.utils.module_loading import import_string
import requests
from requests.auth import HTTPBasicAuth

from mozilla_django_oidc.auth import OIDCAuthenticationBackend
from mozilla_django_oidc.auth import OIDCAuthenticationBackend, store_tokens
from mozilla_django_oidc.utils import (absolutify,
add_state_and_nonce_to_session,
import_from_settings)
Expand Down Expand Up @@ -95,6 +98,11 @@ def exempt_url_patterns(self):
exempt_patterns.add(url_pattern)
return exempt_patterns

@property
def logout_redirect_url(self):
"""Return the logout url defined in settings."""
return self.get_settings('LOGOUT_REDIRECT_URL', '/')

def is_refreshable_url(self, request):
"""Takes a request and returns whether it triggers a refresh examination
Expand All @@ -104,7 +112,7 @@ def is_refreshable_url(self, request):
"""
# Do not attempt to refresh the session if the OIDC backend is not used
backend_session = request.session.get(BACKEND_SESSION_KEY)
backend_session = request.session.get(auth.BACKEND_SESSION_KEY)
is_oidc_enabled = True
if backend_session:
auth_backend = import_string(backend_session)
Expand All @@ -118,27 +126,71 @@ def is_refreshable_url(self, request):
not any(pat.match(request.path) for pat in self.exempt_url_patterns)
)

def process_request(self, request):
def is_expired(self, request):
if not self.is_refreshable_url(request):
LOGGER.debug('request is not refreshable')
return
return False

expiration = request.session.get('oidc_id_token_expiration', 0)
expiration = request.session.get('oidc_token_expiration', 0)
now = time.time()
if expiration > now:
# The id_token is still valid, so we don't have to do anything.
LOGGER.debug('id token is still valid (%s > %s)', expiration, now)
return False

return True

def process_request(self, request):
if not self.is_expired(request):
return

LOGGER.debug('id token has expired')
# The id_token has expired, so we have to re-authenticate silently.
return self.finish(request, prompt_reauth=True)

def finish(self, request, prompt_reauth=True):
"""Finish request handling and handle sending downstream responses for XHR.
This function should only be run if the session is determind to
be expired.
Almost all XHR request handling in client-side code struggles
with redirects since redirecting to a page where the user
is supposed to do something is extremely unlikely to work
in an XHR request. Make a special response for these kinds
of requests.
The use of 403 Forbidden is to match the fact that this
middleware doesn't really want the user in if they don't
refresh their session.
"""
default_response = None
xhr_response_json = {'error': 'the authentication session has expired'}
if prompt_reauth:
# The id_token has expired, so we have to re-authenticate silently.
refresh_url = self._prepare_reauthorization(request)
default_response = HttpResponseRedirect(refresh_url)
xhr_response_json['refresh_url'] = refresh_url

if request.headers.get('x-requested-with') == 'XMLHttpRequest':
xhr_response = JsonResponse(xhr_response_json, status=403)
if 'refresh_url' in xhr_response_json:
xhr_response['refresh_url'] = xhr_response_json['refresh_url']
return xhr_response
else:
return default_response

def _prepare_reauthorization(self, request):
# Constructs a new authorization grant request to refresh the session.
# Besides constructing the request, the state and nonce included in the
# request are registered in the current session in preparation for the
# client following through with the authorization flow.
auth_url = self.OIDC_OP_AUTHORIZATION_ENDPOINT
client_id = self.OIDC_RP_CLIENT_ID
state = get_random_string(self.OIDC_STATE_SIZE)

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

if self.OIDC_USE_NONCE:
nonce = get_random_string(self.OIDC_NONCE_SIZE)
params.update({
auth_params.update({
'nonce': nonce
})

add_state_and_nonce_to_session(request, state, params)

# Register the one-time parameters in the session
add_state_and_nonce_to_session(request, state, auth_params)
request.session['oidc_login_next'] = request.get_full_path()

query = urlencode(params, quote_via=quote)
redirect_url = '{url}?{query}'.format(url=auth_url, query=query)
if request.headers.get('x-requested-with') == 'XMLHttpRequest':
# Almost all XHR request handling in client-side code struggles
# with redirects since redirecting to a page where the user
# is supposed to do something is extremely unlikely to work
# in an XHR request. Make a special response for these kinds
# of requests.
# The use of 403 Forbidden is to match the fact that this
# middleware doesn't really want the user in if they don't
# refresh their session.
response = JsonResponse({'refresh_url': redirect_url}, status=403)
response['refresh_url'] = redirect_url
return response
return HttpResponseRedirect(redirect_url)
query = urlencode(auth_params, quote_via=quote)
return '{auth_url}?{query}'.format(auth_url=auth_url, query=query)


class RefreshOIDCAccessToken(SessionRefresh):
"""
A middleware that will refresh the access token following proper OIDC protocol:
https://auth0.com/docs/tokens/refresh-token/current
"""
def process_request(self, request):
if not self.is_expired(request):
return

token_url = import_from_settings('OIDC_OP_TOKEN_ENDPOINT')
client_id = import_from_settings('OIDC_RP_CLIENT_ID')
client_secret = import_from_settings('OIDC_RP_CLIENT_SECRET')
refresh_token = request.session.get('oidc_refresh_token')

if not refresh_token:
LOGGER.debug('no refresh token stored')
return self.finish(request, prompt_reauth=True)

token_payload = {
'grant_type': 'refresh_token',
'client_id': client_id,
'client_secret': client_secret,
'refresh_token': refresh_token,
}

req_auth = None
if self.get_settings('OIDC_TOKEN_USE_BASIC_AUTH', False):
# When Basic auth is defined, create the Auth Header and remove secret from payload.
user = token_payload.get('client_id')
pw = token_payload.get('client_secret')

req_auth = HTTPBasicAuth(user, pw)
del token_payload['client_secret']

try:
response = requests.post(
token_url,
auth=req_auth,
data=token_payload,
verify=import_from_settings('OIDC_VERIFY_SSL', True)
)
response.raise_for_status()
token_info = response.json()
except requests.exceptions.Timeout:
LOGGER.debug('timed out refreshing access token')
# Don't prompt for reauth as this could be a temporary problem
return self.finish(request, prompt_reauth=False)
except requests.exceptions.HTTPError as exc:
status_code = exc.response.status_code
LOGGER.debug('http error %s when refreshing access token', status_code)
return self.finish(request, prompt_reauth=(status_code == 401))
except json.JSONDecodeError:
LOGGER.debug('malformed response when refreshing access token')
# Don't prompt for reauth as this could be a temporary problem
return self.finish(request, prompt_reauth=False)
except Exception as exc:
LOGGER.debug(
'unknown error occurred when refreshing access token: %s', exc)
# Don't prompt for reauth as this could be a temporary problem
return self.finish(request, prompt_reauth=False)

# Until we can properly validate an ID token on the refresh response
# per the spec[1], we intentionally drop the id_token.
# [1]: https://openid.net/specs/openid-connect-core-1_0.html#RefreshTokenResponse
id_token = None
access_token = token_info.get('access_token')
refresh_token = token_info.get('refresh_token')
store_tokens(request.session, access_token, id_token, refresh_token)
10 changes: 7 additions & 3 deletions mozilla_django_oidc/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,9 +49,13 @@ def login_success(self):
auth.login(self.request, self.user)

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

return HttpResponseRedirect(self.success_url)

Expand Down
Loading

0 comments on commit 7bf3b99

Please sign in to comment.