Skip to content

Commit 29324c0

Browse files
committed
✨(oidc) store and refresh tokens
This provides a way to to refresh the OIDC access token. The OIDC token will be used to request data to a resource server. This code is highly related to mozilla/mozilla-django-oidc#377
1 parent 929a50b commit 29324c0

File tree

6 files changed

+600
-0
lines changed

6 files changed

+600
-0
lines changed

src/backend/core/authentication/backends.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,25 @@
1010
from mozilla_django_oidc.auth import (
1111
OIDCAuthenticationBackend as MozillaOIDCAuthenticationBackend,
1212
)
13+
from mozilla_django_oidc.utils import import_from_settings
1314

1415
from core.models import DuplicateEmailError, User
1516

1617
logger = logging.getLogger(__name__)
1718

1819

20+
def store_tokens(session, access_token, id_token, refresh_token):
21+
"""Store tokens in the session if enabled in settings."""
22+
if import_from_settings("OIDC_STORE_ACCESS_TOKEN", False):
23+
session["oidc_access_token"] = access_token
24+
25+
if import_from_settings("OIDC_STORE_ID_TOKEN", False):
26+
session["oidc_id_token"] = id_token
27+
28+
if import_from_settings("OIDC_STORE_REFRESH_TOKEN", False):
29+
session["oidc_refresh_token"] = refresh_token
30+
31+
1932
class OIDCAuthenticationBackend(MozillaOIDCAuthenticationBackend):
2033
"""Custom OpenID Connect (OIDC) Authentication Backend.
2134
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
"""
2+
Decorators for the authentication app.
3+
4+
We don't want (yet) to enforce the OIDC access token to be "fresh" for all
5+
views, so we provide a decorator to refresh the access token only when needed.
6+
"""
7+
8+
from django.utils.decorators import decorator_from_middleware
9+
10+
from .middleware import RefreshOIDCAccessToken
11+
12+
refresh_oidc_access_token = decorator_from_middleware(RefreshOIDCAccessToken)
Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
1+
"""
2+
Module to declare a RefreshOIDCAccessToken middleware that extends the
3+
mozilla_django_oidc.middleware.SessionRefresh middleware to refresh the
4+
access token when it expires, based on the OIDC provided refresh token.
5+
6+
This is based on https://github.com/mozilla/mozilla-django-oidc/pull/377
7+
which is still not merged.
8+
"""
9+
10+
import json
11+
import logging
12+
import time
13+
from urllib.parse import quote, urlencode
14+
15+
from django.http import JsonResponse
16+
from django.urls import reverse
17+
from django.utils.crypto import get_random_string
18+
19+
import requests
20+
from mozilla_django_oidc.middleware import SessionRefresh
21+
22+
try:
23+
from mozilla_django_oidc.middleware import ( # pylint: disable=unused-import
24+
RefreshOIDCAccessToken as MozillaRefreshOIDCAccessToken,
25+
)
26+
27+
# If the import is successful, raise an error to notify the user that the
28+
# version of mozilla_django_oidc added the expected middleware, and we don't need
29+
# our implementation anymore.
30+
# See https://github.com/mozilla/mozilla-django-oidc/pull/377
31+
raise RuntimeError("This version of mozilla_django_oidc has RefreshOIDCAccessToken")
32+
except ImportError:
33+
pass
34+
35+
from mozilla_django_oidc.utils import (
36+
absolutify,
37+
add_state_and_verifier_and_nonce_to_session,
38+
import_from_settings,
39+
)
40+
41+
from core.authentication.backends import store_tokens
42+
43+
logger = logging.getLogger(__name__)
44+
45+
46+
class RefreshOIDCAccessToken(SessionRefresh):
47+
"""
48+
A middleware that will refresh the access token following proper OIDC protocol:
49+
https://auth0.com/docs/tokens/refresh-token/current
50+
51+
This is based on https://github.com/mozilla/mozilla-django-oidc/pull/377
52+
but limited to our needs (YAGNI/KISS).
53+
"""
54+
55+
def _prepare_reauthorization(self, request):
56+
"""
57+
Constructs a new authorization grant request to refresh the session.
58+
Besides constructing the request, the state and nonce included in the
59+
request are registered in the current session in preparation for the
60+
client following through with the authorization flow.
61+
"""
62+
auth_url = self.OIDC_OP_AUTHORIZATION_ENDPOINT
63+
client_id = self.OIDC_RP_CLIENT_ID
64+
state = get_random_string(self.OIDC_STATE_SIZE)
65+
66+
# Build the parameters as if we were doing a real auth handoff, except
67+
# we also include prompt=none.
68+
auth_params = {
69+
"response_type": "code",
70+
"client_id": client_id,
71+
"redirect_uri": absolutify(
72+
request, reverse(self.OIDC_AUTHENTICATION_CALLBACK_URL)
73+
),
74+
"state": state,
75+
"scope": self.OIDC_RP_SCOPES,
76+
"prompt": "none",
77+
}
78+
79+
if self.OIDC_USE_NONCE:
80+
nonce = get_random_string(self.OIDC_NONCE_SIZE)
81+
auth_params.update({"nonce": nonce})
82+
83+
# Register the one-time parameters in the session
84+
add_state_and_verifier_and_nonce_to_session(request, state, auth_params)
85+
request.session["oidc_login_next"] = request.get_full_path()
86+
87+
query = urlencode(auth_params, quote_via=quote)
88+
return f"{auth_url}?{query}"
89+
90+
def is_expired(self, request):
91+
"""Check whether the access token is expired and needs to be refreshed."""
92+
if not self.is_refreshable_url(request):
93+
logger.debug("request is not refreshable")
94+
return False
95+
96+
expiration = request.session.get("oidc_token_expiration", 0)
97+
now = time.time()
98+
if expiration > now:
99+
# The id_token is still valid, so we don't have to do anything.
100+
logger.debug("id token is still valid (%s > %s)", expiration, now)
101+
return False
102+
103+
return True
104+
105+
def finish(self, request, prompt_reauth=True):
106+
"""Finish request handling and handle sending downstream responses for XHR.
107+
This function should only be run if the session is determind to
108+
be expired.
109+
Almost all XHR request handling in client-side code struggles
110+
with redirects since redirecting to a page where the user
111+
is supposed to do something is extremely unlikely to work
112+
in an XHR request. Make a special response for these kinds
113+
of requests.
114+
The use of 403 Forbidden is to match the fact that this
115+
middleware doesn't really want the user in if they don't
116+
refresh their session.
117+
118+
WARNING: this varies from the original implementation:
119+
- to return a 401 status code
120+
- to consider all requests as XHR requests
121+
"""
122+
xhr_response_json = {"error": "the authentication session has expired"}
123+
if prompt_reauth:
124+
# The id_token has expired, so we have to re-authenticate silently.
125+
refresh_url = self._prepare_reauthorization(request)
126+
xhr_response_json["refresh_url"] = refresh_url
127+
128+
xhr_response = JsonResponse(xhr_response_json, status=401)
129+
if "refresh_url" in xhr_response_json:
130+
xhr_response["refresh_url"] = xhr_response_json["refresh_url"]
131+
return xhr_response
132+
133+
def process_request(self, request): # noqa: PLR0911 # pylint: disable=too-many-return-statements
134+
"""Process the request and refresh the access token if necessary."""
135+
if not self.is_expired(request):
136+
return None
137+
138+
token_url = self.get_settings("OIDC_OP_TOKEN_ENDPOINT")
139+
client_id = self.get_settings("OIDC_RP_CLIENT_ID")
140+
client_secret = self.get_settings("OIDC_RP_CLIENT_SECRET")
141+
refresh_token = request.session.get("oidc_refresh_token")
142+
143+
if not refresh_token:
144+
logger.debug("no refresh token stored")
145+
return self.finish(request, prompt_reauth=True)
146+
147+
token_payload = {
148+
"grant_type": "refresh_token",
149+
"client_id": client_id,
150+
"client_secret": client_secret,
151+
"refresh_token": refresh_token,
152+
}
153+
154+
req_auth = None
155+
if self.get_settings("OIDC_TOKEN_USE_BASIC_AUTH", False):
156+
# supported in https://github.com/mozilla/mozilla-django-oidc/pull/377
157+
# but we don't need it, so enforce error here.
158+
raise RuntimeError("OIDC_TOKEN_USE_BASIC_AUTH is not supported")
159+
160+
try:
161+
response = requests.post(
162+
token_url,
163+
auth=req_auth,
164+
data=token_payload,
165+
verify=import_from_settings("OIDC_VERIFY_SSL", True),
166+
timeout=import_from_settings("OIDC_TIMEOUT", 3),
167+
)
168+
response.raise_for_status()
169+
token_info = response.json()
170+
except requests.exceptions.Timeout:
171+
logger.debug("timed out refreshing access token")
172+
# Don't prompt for reauth as this could be a temporary problem
173+
return self.finish(request, prompt_reauth=False)
174+
except requests.exceptions.HTTPError as exc:
175+
status_code = exc.response.status_code
176+
logger.debug("http error %s when refreshing access token", status_code)
177+
# OAuth error response will be a 400 for various situations, including
178+
# an expired token. https://datatracker.ietf.org/doc/html/rfc6749#section-5.2
179+
return self.finish(request, prompt_reauth=status_code == 400)
180+
except json.JSONDecodeError:
181+
logger.debug("malformed response when refreshing access token")
182+
# Don't prompt for reauth as this could be a temporary problem
183+
return self.finish(request, prompt_reauth=False)
184+
except Exception as exc: # pylint: disable=broad-except
185+
logger.exception(
186+
"unknown error occurred when refreshing access token: %s", exc
187+
)
188+
# Don't prompt for reauth as this could be a temporary problem
189+
return self.finish(request, prompt_reauth=False)
190+
191+
# Until we can properly validate an ID token on the refresh response
192+
# per the spec[1], we intentionally drop the id_token.
193+
# [1]: https://openid.net/specs/openid-connect-core-1_0.html#RefreshTokenResponse
194+
id_token = None
195+
access_token = token_info.get("access_token")
196+
refresh_token = token_info.get("refresh_token")
197+
store_tokens(request.session, access_token, id_token, refresh_token)
198+
199+
return None
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
"""Tests for the refresh_oidc_access_token decorator in core app."""
2+
3+
from unittest.mock import patch
4+
5+
from django.http import HttpResponse
6+
from django.test import RequestFactory
7+
from django.utils.decorators import method_decorator
8+
from django.views import View
9+
10+
from core.authentication.decorators import refresh_oidc_access_token
11+
12+
13+
class RefreshOIDCAccessTokenView(View):
14+
"""
15+
A Django view that uses the refresh_oidc_access_token decorator to refresh
16+
the OIDC access token before processing the request.
17+
"""
18+
19+
@method_decorator(refresh_oidc_access_token)
20+
def dispatch(self, request, *args, **kwargs):
21+
"""
22+
Overrides the dispatch method to apply the refresh_oidc_access_token decorator.
23+
"""
24+
return super().dispatch(request, *args, **kwargs)
25+
26+
def get(self, request, *args, **kwargs):
27+
"""
28+
Handles GET requests.
29+
30+
Returns:
31+
HttpResponse: A simple HTTP response with "OK" as the content.
32+
"""
33+
return HttpResponse("OK")
34+
35+
36+
def test_refresh_oidc_access_token_decorator():
37+
"""
38+
Tests the refresh_oidc_access_token decorator is called on RefreshOIDCAccessTokenView access.
39+
40+
The test creates a mock request and patches the dispatch method to verify that it is called
41+
with the correct request object.
42+
"""
43+
# Create a test request
44+
factory = RequestFactory()
45+
request = factory.get("/")
46+
47+
# Mock the OIDC refresh functionality
48+
with patch(
49+
"core.authentication.middleware.RefreshOIDCAccessToken.process_request"
50+
) as mock_refresh:
51+
# Call the decorated view
52+
RefreshOIDCAccessTokenView.as_view()(request)
53+
54+
# Assert that the refresh method was called
55+
mock_refresh.assert_called_once_with(request)

0 commit comments

Comments
 (0)