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 RefreshOIDCToken middleware #301

Open
wants to merge 4 commits 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
13 changes: 13 additions & 0 deletions HISTORY.rst
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,19 @@

History
-------
0.4.2 (2017-11-13)
++++++++++++++++++

Features:

* RS256 verification through ``settings.OIDC_OP_JWKS_ENDPOINT``

1.3.0 (2019-03-12)
++++++++++++++++++

* Add `RefreshOIDCToken` middleware
* Add `settings.OIDC_STORE_REFRESH_TOKEN`
Thanks `@GermanoGuerrini`_

1.2.1 (2019-01-22)
++++++++++++++++++
Expand Down
28 changes: 28 additions & 0 deletions docs/installation.rst
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,34 @@ 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.


Getting a new ID 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.RefreshOIDCToken` middleware.

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.RefreshOIDCToken',
# ...
]

The middleware will check if the user's id token has expired with the same logic
of :py:class:`mozilla_django_oidc.middleware.SessionRefresh` but, instead of
re-authenticating the user, it will request the OIDC Provider for a new
id token.

.. seealso::

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


Connecting OIDC user identities to Django users
-----------------------------------------------

Expand Down
9 changes: 8 additions & 1 deletion docs/settings.rst
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ of ``mozilla-django-oidc``.

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

.. py:attribute:: OIDC_CREATE_USER

Expand Down Expand Up @@ -141,6 +141,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
26 changes: 14 additions & 12 deletions mozilla_django_oidc/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,18 @@ def default_username_algo(email):
return smart_text(username)


def store_tokens(session, access_token, id_token, refresh_token):
"""Store OIDC tokens."""
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 @@ -270,12 +282,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)
store_tokens(self.request.session, access_token, id_token, refresh_token)
try:
return self.get_or_create_user(access_token, id_token, payload)
except SuspiciousOperation as exc:
Expand All @@ -284,16 +296,6 @@ def authenticate(self, request, **kwargs):

return None

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

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

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
and configured to do so. Returns nothing if multiple users are matched."""
Expand Down
51 changes: 48 additions & 3 deletions mozilla_django_oidc/middleware.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,9 @@
from django.utils.functional import cached_property
from django.utils.module_loading import import_string

from mozilla_django_oidc.auth import OIDCAuthenticationBackend
import requests

from mozilla_django_oidc.auth import OIDCAuthenticationBackend, store_tokens
from mozilla_django_oidc.utils import (
absolutify,
import_from_settings,
Expand Down Expand Up @@ -82,16 +84,23 @@ def is_refreshable_url(self, request):
request.path not in self.exempt_urls
)

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)
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 @@ -139,3 +148,39 @@ def process_request(self, request):
response['refresh_url'] = redirect_url
return response
return HttpResponseRedirect(redirect_url)


class RefreshOIDCToken(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()
id_token = token_info.get('id_token')
access_token = token_info.get('access_token')
refresh_token = token_info.get('refresh_token')
store_tokens(request.session, id_token, access_token, refresh_token)
87 changes: 44 additions & 43 deletions tests/test_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives import hashes, hmac
from josepy.b64 import b64encode
import requests

from django.conf import settings
from django.contrib.auth import get_user_model
Expand Down Expand Up @@ -805,49 +806,6 @@ def update_user(user, claims):
self.assertEqual(User.objects.get().first_name, 'a_username')


class OIDCAuthenticationBackendRS256WithKeyTestCase(TestCase):
"""Authentication tests with ALG RS256 and provided IdP Sign Key."""

@override_settings(OIDC_OP_TOKEN_ENDPOINT='https://server.example.com/token')
@override_settings(OIDC_OP_USER_ENDPOINT='https://server.example.com/user')
@override_settings(OIDC_RP_CLIENT_ID='example_id')
@override_settings(OIDC_RP_CLIENT_SECRET='client_secret')
@override_settings(OIDC_RP_SIGN_ALGO='RS256')
@override_settings(OIDC_RP_IDP_SIGN_KEY='sign_key')
def setUp(self):
self.backend = OIDCAuthenticationBackend()

@override_settings(OIDC_USE_NONCE=False)
@patch('mozilla_django_oidc.auth.OIDCAuthenticationBackend._verify_jws')
@patch('mozilla_django_oidc.auth.requests')
def test_jwt_verify_sign_key(self, request_mock, jws_mock):
"""Test jwt verification signature."""
auth_request = RequestFactory().get('/foo', {'code': 'foo',
'state': 'bar'})
auth_request.session = {}

jws_mock.return_value = json.dumps({
'aud': 'audience'
}).encode('utf-8')
get_json_mock = Mock()
get_json_mock.json.return_value = {
'nickname': 'username',
'email': '[email protected]'
}
request_mock.get.return_value = get_json_mock
post_json_mock = Mock()
post_json_mock.json.return_value = {
'id_token': 'token',
'access_token': 'access_token'
}
request_mock.post.return_value = post_json_mock
self.backend.authenticate(request=auth_request)
calls = [
call(force_bytes('token'), 'sign_key')
]
jws_mock.assert_has_calls(calls)


class OIDCAuthenticationBackendRS256WithJwksEndpointTestCase(TestCase):
"""Authentication tests with ALG RS256 and IpD JWKS Endpoint."""

Expand Down Expand Up @@ -1105,5 +1063,48 @@ def test_returns_true_custom_claims(self, patch_logger, patch_settings):
patch_logger.warning.assert_called_with(msg)


class OIDCAuthenticationBackendRS256WithKeyTestCase(TestCase):
"""Authentication tests with ALG RS256 and provided IdP Sign Key."""

@override_settings(OIDC_OP_TOKEN_ENDPOINT='https://server.example.com/token')
@override_settings(OIDC_OP_USER_ENDPOINT='https://server.example.com/user')
@override_settings(OIDC_RP_CLIENT_ID='example_id')
@override_settings(OIDC_RP_CLIENT_SECRET='client_secret')
@override_settings(OIDC_RP_SIGN_ALGO='RS256')
@override_settings(OIDC_RP_IDP_SIGN_KEY='sign_key')
def setUp(self):
self.backend = OIDCAuthenticationBackend()

@override_settings(OIDC_USE_NONCE=False)
@patch('mozilla_django_oidc.auth.OIDCAuthenticationBackend._verify_jws')
@patch('mozilla_django_oidc.auth.requests')
def test_jwt_verify_sign_key(self, request_mock, jws_mock):
"""Test jwt verification signature."""
auth_request = RequestFactory().get('/foo', {'code': 'foo',
'state': 'bar'})
auth_request.session = {}

jws_mock.return_value = json.dumps({
'aud': 'audience'
}).encode('utf-8')
get_json_mock = Mock()
get_json_mock.json.return_value = {
'nickname': 'username',
'email': '[email protected]'
}
request_mock.get.return_value = get_json_mock
post_json_mock = Mock()
post_json_mock.json.return_value = {
'id_token': 'token',
'access_token': 'access_token'
}
request_mock.post.return_value = post_json_mock
self.backend.authenticate(request=auth_request)
calls = [
call(force_bytes('token'), 'sign_key')
]
jws_mock.assert_has_calls(calls)


def dotted_username_algo_callback(email):
return 'dotted_username_algo'
Loading