Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add RefreshOIDCAccessToken middleware #377

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
163 changes: 137 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,85 @@ 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(
diurnalist marked this conversation as resolved.
Show resolved Hide resolved
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)
# OAuth error response will be a 400 for various situations, including
# an expired token. https://datatracker.ietf.org/doc/html/rfc6749#section-5.2
return self.finish(request, prompt_reauth=(status_code == 400))
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')

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The refresh token is optional.
https://www.ietf.org/rfc/rfc6749.txt

So this doesn't work, for example, for amazon cognito, as they don't return new refresh token.
https://docs.aws.amazon.com/cognito/latest/developerguide/token-endpoint.html

I think thee refresh_token should only be stored when it's contained in the response, otherwise keep the old one.

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

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
self.request.session['oidc_token_expiration'] = time.time() + expiration_interval
self.request.session['oidc_id_token_expiration'] = time.time() + expiration_interval


return HttpResponseRedirect(self.success_url)

Expand Down
Loading