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).
  • Loading branch information
Germano Guerrini authored and Jason Anderson committed Oct 6, 2020
1 parent 2bd65e2 commit fbb7235
Show file tree
Hide file tree
Showing 6 changed files with 247 additions and 40 deletions.
48 changes: 45 additions & 3 deletions docs/installation.rst
Original file line number Diff line number Diff line change
Expand Up @@ -144,7 +144,7 @@ Next, edit your ``urls.py`` and add the following:
.. code-block:: python
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 @@ -79,7 +79,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 @@ -168,6 +169,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 @@ -42,6 +42,17 @@ def default_username_algo(email):
return smart_text(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 @@ -280,12 +291,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 @@ -294,15 +305,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
56 changes: 52 additions & 4 deletions mozilla_django_oidc/middleware.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,9 @@
from django.utils.deprecation import MiddlewareMixin
from django.utils.functional import cached_property
from django.utils.module_loading import import_string
import requests

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 @@ -122,16 +123,24 @@ 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')
Expand Down Expand Up @@ -179,3 +188,42 @@ def process_request(self, request):
response['refresh_url'] = redirect_url
return response
return HttpResponseRedirect(redirect_url)


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

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

response = requests.post(token_url,
data=token_payload,
verify=import_from_settings('OIDC_VERIFY_SSL', True))
response.raise_for_status()

token_info = response.json()
# 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 @@ -47,9 +47,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 fbb7235

Please sign in to comment.