1+ import json
12import logging
23import time
34
4- from django .contrib . auth import BACKEND_SESSION_KEY
5+ from django .contrib import auth
56from django .http import HttpResponseRedirect , JsonResponse
67from django .urls import reverse
78from django .utils .crypto import get_random_string
89from django .utils .deprecation import MiddlewareMixin
910from django .utils .functional import cached_property
1011from django .utils .module_loading import import_string
12+ import requests
13+ from requests .auth import HTTPBasicAuth
1114
12- from mozilla_django_oidc .auth import OIDCAuthenticationBackend
15+ from mozilla_django_oidc .auth import OIDCAuthenticationBackend , store_tokens
1316from mozilla_django_oidc .utils import (absolutify ,
1417 add_state_and_nonce_to_session ,
1518 import_from_settings )
@@ -95,6 +98,11 @@ def exempt_url_patterns(self):
9598 exempt_patterns .add (url_pattern )
9699 return exempt_patterns
97100
101+ @property
102+ def logout_redirect_url (self ):
103+ """Return the logout url defined in settings."""
104+ return self .get_settings ('LOGOUT_REDIRECT_URL' , '/' )
105+
98106 def is_refreshable_url (self , request ):
99107 """Takes a request and returns whether it triggers a refresh examination
100108
@@ -104,7 +112,7 @@ def is_refreshable_url(self, request):
104112
105113 """
106114 # Do not attempt to refresh the session if the OIDC backend is not used
107- backend_session = request .session .get (BACKEND_SESSION_KEY )
115+ backend_session = request .session .get (auth . BACKEND_SESSION_KEY )
108116 is_oidc_enabled = True
109117 if backend_session :
110118 auth_backend = import_string (backend_session )
@@ -118,27 +126,71 @@ def is_refreshable_url(self, request):
118126 not any (pat .match (request .path ) for pat in self .exempt_url_patterns )
119127 )
120128
121- def process_request (self , request ):
129+ def is_expired (self , request ):
122130 if not self .is_refreshable_url (request ):
123131 LOGGER .debug ('request is not refreshable' )
124- return
132+ return False
125133
126- expiration = request .session .get ('oidc_id_token_expiration ' , 0 )
134+ expiration = request .session .get ('oidc_token_expiration ' , 0 )
127135 now = time .time ()
128136 if expiration > now :
129137 # The id_token is still valid, so we don't have to do anything.
130138 LOGGER .debug ('id token is still valid (%s > %s)' , expiration , now )
139+ return False
140+
141+ return True
142+
143+ def process_request (self , request ):
144+ if not self .is_expired (request ):
131145 return
132146
133147 LOGGER .debug ('id token has expired' )
134- # The id_token has expired, so we have to re-authenticate silently.
148+ return self .finish (request , prompt_reauth = True )
149+
150+ def finish (self , request , prompt_reauth = True ):
151+ """Finish request handling and handle sending downstream responses for XHR.
152+
153+ This function should only be run if the session is determind to
154+ be expired.
155+
156+ Almost all XHR request handling in client-side code struggles
157+ with redirects since redirecting to a page where the user
158+ is supposed to do something is extremely unlikely to work
159+ in an XHR request. Make a special response for these kinds
160+ of requests.
161+
162+ The use of 403 Forbidden is to match the fact that this
163+ middleware doesn't really want the user in if they don't
164+ refresh their session.
165+ """
166+ default_response = None
167+ xhr_response_json = {'error' : 'the authentication session has expired' }
168+ if prompt_reauth :
169+ # The id_token has expired, so we have to re-authenticate silently.
170+ refresh_url = self ._prepare_reauthorization (request )
171+ default_response = HttpResponseRedirect (refresh_url )
172+ xhr_response_json ['refresh_url' ] = refresh_url
173+
174+ if request .headers .get ('x-requested-with' ) == 'XMLHttpRequest' :
175+ xhr_response = JsonResponse (xhr_response_json , status = 403 )
176+ if 'refresh_url' in xhr_response_json :
177+ xhr_response ['refresh_url' ] = xhr_response_json ['refresh_url' ]
178+ return xhr_response
179+ else :
180+ return default_response
181+
182+ def _prepare_reauthorization (self , request ):
183+ # Constructs a new authorization grant request to refresh the session.
184+ # Besides constructing the request, the state and nonce included in the
185+ # request are registered in the current session in preparation for the
186+ # client following through with the authorization flow.
135187 auth_url = self .OIDC_OP_AUTHORIZATION_ENDPOINT
136188 client_id = self .OIDC_RP_CLIENT_ID
137189 state = get_random_string (self .OIDC_STATE_SIZE )
138190
139191 # Build the parameters as if we were doing a real auth handoff, except
140192 # we also include prompt=none.
141- params = {
193+ auth_params = {
142194 'response_type' : 'code' ,
143195 'client_id' : client_id ,
144196 'redirect_uri' : absolutify (
@@ -152,26 +204,83 @@ def process_request(self, request):
152204
153205 if self .OIDC_USE_NONCE :
154206 nonce = get_random_string (self .OIDC_NONCE_SIZE )
155- params .update ({
207+ auth_params .update ({
156208 'nonce' : nonce
157209 })
158210
159- add_state_and_nonce_to_session ( request , state , params )
160-
211+ # Register the one-time parameters in the session
212+ add_state_and_nonce_to_session ( request , state , auth_params )
161213 request .session ['oidc_login_next' ] = request .get_full_path ()
162214
163- query = urlencode (params , quote_via = quote )
164- redirect_url = '{url}?{query}' .format (url = auth_url , query = query )
165- if request .headers .get ('x-requested-with' ) == 'XMLHttpRequest' :
166- # Almost all XHR request handling in client-side code struggles
167- # with redirects since redirecting to a page where the user
168- # is supposed to do something is extremely unlikely to work
169- # in an XHR request. Make a special response for these kinds
170- # of requests.
171- # The use of 403 Forbidden is to match the fact that this
172- # middleware doesn't really want the user in if they don't
173- # refresh their session.
174- response = JsonResponse ({'refresh_url' : redirect_url }, status = 403 )
175- response ['refresh_url' ] = redirect_url
176- return response
177- return HttpResponseRedirect (redirect_url )
215+ query = urlencode (auth_params , quote_via = quote )
216+ return '{auth_url}?{query}' .format (auth_url = auth_url , query = query )
217+
218+
219+ class RefreshOIDCAccessToken (SessionRefresh ):
220+ """
221+ A middleware that will refresh the access token following proper OIDC protocol:
222+ https://auth0.com/docs/tokens/refresh-token/current
223+ """
224+ def process_request (self , request ):
225+ if not self .is_expired (request ):
226+ return
227+
228+ token_url = import_from_settings ('OIDC_OP_TOKEN_ENDPOINT' )
229+ client_id = import_from_settings ('OIDC_RP_CLIENT_ID' )
230+ client_secret = import_from_settings ('OIDC_RP_CLIENT_SECRET' )
231+ refresh_token = request .session .get ('oidc_refresh_token' )
232+
233+ if not refresh_token :
234+ LOGGER .debug ('no refresh token stored' )
235+ return self .finish (request , prompt_reauth = True )
236+
237+ token_payload = {
238+ 'grant_type' : 'refresh_token' ,
239+ 'client_id' : client_id ,
240+ 'client_secret' : client_secret ,
241+ 'refresh_token' : refresh_token ,
242+ }
243+
244+ req_auth = None
245+ if self .get_settings ('OIDC_TOKEN_USE_BASIC_AUTH' , False ):
246+ # When Basic auth is defined, create the Auth Header and remove secret from payload.
247+ user = token_payload .get ('client_id' )
248+ pw = token_payload .get ('client_secret' )
249+
250+ req_auth = HTTPBasicAuth (user , pw )
251+ del token_payload ['client_secret' ]
252+
253+ try :
254+ response = requests .post (
255+ token_url ,
256+ auth = req_auth ,
257+ data = token_payload ,
258+ verify = import_from_settings ('OIDC_VERIFY_SSL' , True )
259+ )
260+ response .raise_for_status ()
261+ token_info = response .json ()
262+ except requests .exceptions .Timeout :
263+ LOGGER .debug ('timed out refreshing access token' )
264+ # Don't prompt for reauth as this could be a temporary problem
265+ return self .finish (request , prompt_reauth = False )
266+ except requests .exceptions .HTTPError as exc :
267+ status_code = exc .response .status_code
268+ LOGGER .debug ('http error %s when refreshing access token' , status_code )
269+ return self .finish (request , prompt_reauth = (status_code == 401 ))
270+ except json .JSONDecodeError :
271+ LOGGER .debug ('malformed response when refreshing access token' )
272+ # Don't prompt for reauth as this could be a temporary problem
273+ return self .finish (request , prompt_reauth = False )
274+ except Exception as exc :
275+ LOGGER .debug (
276+ 'unknown error occurred when refreshing access token: %s' , exc )
277+ # Don't prompt for reauth as this could be a temporary problem
278+ return self .finish (request , prompt_reauth = False )
279+
280+ # Until we can properly validate an ID token on the refresh response
281+ # per the spec[1], we intentionally drop the id_token.
282+ # [1]: https://openid.net/specs/openid-connect-core-1_0.html#RefreshTokenResponse
283+ id_token = None
284+ access_token = token_info .get ('access_token' )
285+ refresh_token = token_info .get ('refresh_token' )
286+ store_tokens (request .session , access_token , id_token , refresh_token )
0 commit comments