Skip to content

Commit e4b06eb

Browse files
authored
Refactor RPInitiatedLogoutView (#1274)
1 parent 0965100 commit e4b06eb

File tree

3 files changed

+220
-10
lines changed

3 files changed

+220
-10
lines changed

docs/advanced_topics.rst

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,3 +100,47 @@ You might want to completely bypass the authorization form, for instance if your
100100
in-house product or if you already trust the application owner by other means. To this end, you have to
101101
set ``skip_authorization = True`` on the ``Application`` model, either programmatically or within the
102102
Django admin. Users will *not* be prompted for authorization, even on the first use of the application.
103+
104+
105+
.. _override-views:
106+
107+
Overriding views
108+
================
109+
110+
You may want to override whole views from Django OAuth Toolkit, for instance if you want to
111+
change the login view for unregistred users depending on some query params.
112+
113+
In order to do that, you need to write a custom urlpatterns
114+
115+
.. code-block:: python
116+
117+
from django.urls import re_path
118+
from oauth2_provider import views as oauth2_views
119+
from oauth2_provider import urls
120+
121+
from .views import CustomeAuthorizationView
122+
123+
124+
app_name = "oauth2_provider"
125+
126+
urlpatterns = [
127+
# Base urls
128+
re_path(r"^authorize/", CustomeAuthorizationView.as_view(), name="authorize"),
129+
re_path(r"^token/$", oauth2_views.TokenView.as_view(), name="token"),
130+
re_path(r"^revoke_token/$", oauth2_views.RevokeTokenView.as_view(), name="revoke-token"),
131+
re_path(r"^introspect/$", oauth2_views.IntrospectTokenView.as_view(), name="introspect"),
132+
] + urls.management_urlpatterns + urls.oidc_urlpatterns
133+
134+
You can then replace ``oauth2_provider.urls`` with the path to your urls file, but make sure you keep the
135+
same namespace as before.
136+
137+
.. code-block:: python
138+
139+
from django.urls import include, path
140+
141+
urlpatterns = [
142+
...
143+
path('o/', include('path.to.custom.urls', namespace='oauth2_provider')),
144+
]
145+
146+
This method also allows to remove some of the urls (such as managements) urls if you don't want them.

oauth2_provider/views/oidc.py

Lines changed: 89 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import json
2+
import warnings
23
from urllib.parse import urlparse
34

45
from django.contrib.auth import logout
@@ -225,6 +226,8 @@ def validate_logout_request(request, id_token_hint, client_id, post_logout_redir
225226
will be validated against each other.
226227
"""
227228

229+
warnings.warn("This method is deprecated and will be removed in version 2.5.0.", DeprecationWarning)
230+
228231
id_token = None
229232
must_prompt_logout = True
230233
token_user = None
@@ -315,17 +318,16 @@ def get(self, request, *args, **kwargs):
315318
state = request.GET.get("state")
316319

317320
try:
318-
prompt, (redirect_uri, application), token_user = validate_logout_request(
319-
request=request,
321+
application, token_user = self.validate_logout_request(
320322
id_token_hint=id_token_hint,
321323
client_id=client_id,
322324
post_logout_redirect_uri=post_logout_redirect_uri,
323325
)
324326
except OIDCError as error:
325327
return self.error_response(error)
326328

327-
if not prompt:
328-
return self.do_logout(application, redirect_uri, state, token_user)
329+
if not self.must_prompt(token_user):
330+
return self.do_logout(application, post_logout_redirect_uri, state, token_user)
329331

330332
self.oidc_data = {
331333
"id_token_hint": id_token_hint,
@@ -347,21 +349,100 @@ def form_valid(self, form):
347349
state = form.cleaned_data.get("state")
348350

349351
try:
350-
prompt, (redirect_uri, application), token_user = validate_logout_request(
351-
request=self.request,
352+
application, token_user = self.validate_logout_request(
352353
id_token_hint=id_token_hint,
353354
client_id=client_id,
354355
post_logout_redirect_uri=post_logout_redirect_uri,
355356
)
356357

357-
if not prompt or form.cleaned_data.get("allow"):
358-
return self.do_logout(application, redirect_uri, state, token_user)
358+
if not self.must_prompt(token_user) or form.cleaned_data.get("allow"):
359+
return self.do_logout(application, post_logout_redirect_uri, state, token_user)
359360
else:
360361
raise LogoutDenied()
361362

362363
except OIDCError as error:
363364
return self.error_response(error)
364365

366+
def validate_post_logout_redirect_uri(self, application, post_logout_redirect_uri):
367+
"""
368+
Validate the OIDC RP-Initiated Logout Request post_logout_redirect_uri parameter
369+
"""
370+
371+
if not post_logout_redirect_uri:
372+
return
373+
374+
if not application:
375+
raise InvalidOIDCClientError()
376+
scheme = urlparse(post_logout_redirect_uri)[0]
377+
if not scheme:
378+
raise InvalidOIDCRedirectURIError("A Scheme is required for the redirect URI.")
379+
if oauth2_settings.OIDC_RP_INITIATED_LOGOUT_STRICT_REDIRECT_URIS and (
380+
scheme == "http" and application.client_type != "confidential"
381+
):
382+
raise InvalidOIDCRedirectURIError("http is only allowed with confidential clients.")
383+
if scheme not in application.get_allowed_schemes():
384+
raise InvalidOIDCRedirectURIError(f'Redirect to scheme "{scheme}" is not permitted.')
385+
if not application.post_logout_redirect_uri_allowed(post_logout_redirect_uri):
386+
raise InvalidOIDCRedirectURIError("This client does not have this redirect uri registered.")
387+
388+
def validate_logout_request_user(self, id_token_hint, client_id):
389+
"""
390+
Validate the an OIDC RP-Initiated Logout Request user
391+
"""
392+
393+
if not id_token_hint:
394+
return
395+
396+
# Only basic validation has been done on the IDToken at this point.
397+
id_token, claims = _load_id_token(id_token_hint)
398+
399+
if not id_token or not _validate_claims(self.request, claims):
400+
raise InvalidIDTokenError()
401+
402+
# If both id_token_hint and client_id are given it must be verified that they match.
403+
if client_id:
404+
if id_token.application.client_id != client_id:
405+
raise ClientIdMissmatch()
406+
407+
return id_token
408+
409+
def get_request_application(self, id_token, client_id):
410+
if client_id:
411+
return get_application_model().objects.get(client_id=client_id)
412+
if id_token:
413+
return id_token.application
414+
415+
def validate_logout_request(self, id_token_hint, client_id, post_logout_redirect_uri):
416+
"""
417+
Validate an OIDC RP-Initiated Logout Request.
418+
`(application, token_user)` is returned.
419+
420+
If it is set, `application` is the Application that is requesting the logout.
421+
`token_user` is the id_token user, which will used to revoke the tokens if found.
422+
423+
The `id_token_hint` will be validated if given. If both `client_id` and `id_token_hint` are given they
424+
will be validated against each other.
425+
"""
426+
427+
id_token = self.validate_logout_request_user(id_token_hint, client_id)
428+
application = self.get_request_application(id_token, client_id)
429+
self.validate_post_logout_redirect_uri(application, post_logout_redirect_uri)
430+
431+
return application, id_token.user if id_token else None
432+
433+
def must_prompt(self, token_user):
434+
"""Indicate whether the logout has to be confirmed by the user. This happens if the
435+
specifications force a confirmation, or it is enabled by `OIDC_RP_INITIATED_LOGOUT_ALWAYS_PROMPT`.
436+
437+
A logout without user interaction (i.e. no prompt) is only allowed
438+
if an ID Token is provided that matches the current user.
439+
"""
440+
return (
441+
oauth2_settings.OIDC_RP_INITIATED_LOGOUT_ALWAYS_PROMPT
442+
or token_user is None
443+
or token_user != self.request.user
444+
)
445+
365446
def do_logout(self, application=None, post_logout_redirect_uri=None, state=None, token_user=None):
366447
user = token_user or self.request.user
367448
# Delete Access Tokens if a user was found

tests/test_oidc_views.py

Lines changed: 87 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,12 @@
1010
from oauth2_provider.models import get_access_token_model, get_id_token_model, get_refresh_token_model
1111
from oauth2_provider.oauth2_validators import OAuth2Validator
1212
from oauth2_provider.settings import oauth2_settings
13-
from oauth2_provider.views.oidc import _load_id_token, _validate_claims, validate_logout_request
13+
from oauth2_provider.views.oidc import (
14+
RPInitiatedLogoutView,
15+
_load_id_token,
16+
_validate_claims,
17+
validate_logout_request,
18+
)
1419

1520
from . import presets
1621

@@ -187,7 +192,9 @@ def mock_request_for(user):
187192

188193
@pytest.mark.django_db
189194
@pytest.mark.parametrize("ALWAYS_PROMPT", [True, False])
190-
def test_validate_logout_request(oidc_tokens, public_application, other_user, rp_settings, ALWAYS_PROMPT):
195+
def test_deprecated_validate_logout_request(
196+
oidc_tokens, public_application, other_user, rp_settings, ALWAYS_PROMPT
197+
):
191198
rp_settings.OIDC_RP_INITIATED_LOGOUT_ALWAYS_PROMPT = ALWAYS_PROMPT
192199
oidc_tokens = oidc_tokens
193200
application = oidc_tokens.application
@@ -266,6 +273,84 @@ def test_validate_logout_request(oidc_tokens, public_application, other_user, rp
266273
)
267274

268275

276+
@pytest.mark.django_db
277+
def test_validate_logout_request(oidc_tokens, public_application):
278+
oidc_tokens = oidc_tokens
279+
application = oidc_tokens.application
280+
client_id = application.client_id
281+
id_token = oidc_tokens.id_token
282+
view = RPInitiatedLogoutView()
283+
view.request = mock_request_for(oidc_tokens.user)
284+
assert view.validate_logout_request(
285+
id_token_hint=None,
286+
client_id=None,
287+
post_logout_redirect_uri=None,
288+
) == (None, None)
289+
assert view.validate_logout_request(
290+
id_token_hint=None,
291+
client_id=client_id,
292+
post_logout_redirect_uri=None,
293+
) == (application, None)
294+
assert view.validate_logout_request(
295+
id_token_hint=None,
296+
client_id=client_id,
297+
post_logout_redirect_uri="http://example.org",
298+
) == (application, None)
299+
assert view.validate_logout_request(
300+
id_token_hint=id_token,
301+
client_id=None,
302+
post_logout_redirect_uri="http://example.org",
303+
) == (application, oidc_tokens.user)
304+
assert view.validate_logout_request(
305+
id_token_hint=id_token,
306+
client_id=client_id,
307+
post_logout_redirect_uri="http://example.org",
308+
) == (application, oidc_tokens.user)
309+
with pytest.raises(ClientIdMissmatch):
310+
view.validate_logout_request(
311+
id_token_hint=id_token,
312+
client_id=public_application.client_id,
313+
post_logout_redirect_uri="http://other.org",
314+
)
315+
with pytest.raises(InvalidOIDCClientError):
316+
view.validate_logout_request(
317+
id_token_hint=None,
318+
client_id=None,
319+
post_logout_redirect_uri="http://example.org",
320+
)
321+
with pytest.raises(InvalidOIDCRedirectURIError):
322+
view.validate_logout_request(
323+
id_token_hint=None,
324+
client_id=client_id,
325+
post_logout_redirect_uri="example.org",
326+
)
327+
with pytest.raises(InvalidOIDCRedirectURIError):
328+
view.validate_logout_request(
329+
id_token_hint=None,
330+
client_id=client_id,
331+
post_logout_redirect_uri="imap://example.org",
332+
)
333+
with pytest.raises(InvalidOIDCRedirectURIError):
334+
view.validate_logout_request(
335+
id_token_hint=None,
336+
client_id=client_id,
337+
post_logout_redirect_uri="http://other.org",
338+
)
339+
340+
341+
@pytest.mark.django_db
342+
@pytest.mark.parametrize("ALWAYS_PROMPT", [True, False])
343+
def test_must_prompt(oidc_tokens, other_user, rp_settings, ALWAYS_PROMPT):
344+
rp_settings.OIDC_RP_INITIATED_LOGOUT_ALWAYS_PROMPT = ALWAYS_PROMPT
345+
oidc_tokens = oidc_tokens
346+
assert RPInitiatedLogoutView(request=mock_request_for(oidc_tokens.user)).must_prompt(None) is True
347+
assert (
348+
RPInitiatedLogoutView(request=mock_request_for(oidc_tokens.user)).must_prompt(oidc_tokens.user)
349+
== ALWAYS_PROMPT
350+
)
351+
assert RPInitiatedLogoutView(request=mock_request_for(other_user)).must_prompt(oidc_tokens.user) is True
352+
353+
269354
def test__load_id_token():
270355
assert _load_id_token("Not a Valid ID Token.") == (None, None)
271356

0 commit comments

Comments
 (0)