Skip to content

Commit

Permalink
Refactor RPInitiatedLogoutView (#1274)
Browse files Browse the repository at this point in the history
  • Loading branch information
tonial committed Sep 15, 2023
1 parent 0965100 commit e4b06eb
Show file tree
Hide file tree
Showing 3 changed files with 220 additions and 10 deletions.
44 changes: 44 additions & 0 deletions docs/advanced_topics.rst
Original file line number Diff line number Diff line change
Expand Up @@ -100,3 +100,47 @@ You might want to completely bypass the authorization form, for instance if your
in-house product or if you already trust the application owner by other means. To this end, you have to
set ``skip_authorization = True`` on the ``Application`` model, either programmatically or within the
Django admin. Users will *not* be prompted for authorization, even on the first use of the application.


.. _override-views:

Overriding views
================

You may want to override whole views from Django OAuth Toolkit, for instance if you want to
change the login view for unregistred users depending on some query params.

In order to do that, you need to write a custom urlpatterns

.. code-block:: python
from django.urls import re_path
from oauth2_provider import views as oauth2_views
from oauth2_provider import urls
from .views import CustomeAuthorizationView
app_name = "oauth2_provider"
urlpatterns = [
# Base urls
re_path(r"^authorize/", CustomeAuthorizationView.as_view(), name="authorize"),
re_path(r"^token/$", oauth2_views.TokenView.as_view(), name="token"),
re_path(r"^revoke_token/$", oauth2_views.RevokeTokenView.as_view(), name="revoke-token"),
re_path(r"^introspect/$", oauth2_views.IntrospectTokenView.as_view(), name="introspect"),
] + urls.management_urlpatterns + urls.oidc_urlpatterns
You can then replace ``oauth2_provider.urls`` with the path to your urls file, but make sure you keep the
same namespace as before.

.. code-block:: python
from django.urls import include, path
urlpatterns = [
...
path('o/', include('path.to.custom.urls', namespace='oauth2_provider')),
]
This method also allows to remove some of the urls (such as managements) urls if you don't want them.
97 changes: 89 additions & 8 deletions oauth2_provider/views/oidc.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import json
import warnings
from urllib.parse import urlparse

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

warnings.warn("This method is deprecated and will be removed in version 2.5.0.", DeprecationWarning)

id_token = None
must_prompt_logout = True
token_user = None
Expand Down Expand Up @@ -315,17 +318,16 @@ def get(self, request, *args, **kwargs):
state = request.GET.get("state")

try:
prompt, (redirect_uri, application), token_user = validate_logout_request(
request=request,
application, token_user = self.validate_logout_request(
id_token_hint=id_token_hint,
client_id=client_id,
post_logout_redirect_uri=post_logout_redirect_uri,
)
except OIDCError as error:
return self.error_response(error)

if not prompt:
return self.do_logout(application, redirect_uri, state, token_user)
if not self.must_prompt(token_user):
return self.do_logout(application, post_logout_redirect_uri, state, token_user)

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

try:
prompt, (redirect_uri, application), token_user = validate_logout_request(
request=self.request,
application, token_user = self.validate_logout_request(
id_token_hint=id_token_hint,
client_id=client_id,
post_logout_redirect_uri=post_logout_redirect_uri,
)

if not prompt or form.cleaned_data.get("allow"):
return self.do_logout(application, redirect_uri, state, token_user)
if not self.must_prompt(token_user) or form.cleaned_data.get("allow"):
return self.do_logout(application, post_logout_redirect_uri, state, token_user)
else:
raise LogoutDenied()

except OIDCError as error:
return self.error_response(error)

def validate_post_logout_redirect_uri(self, application, post_logout_redirect_uri):
"""
Validate the OIDC RP-Initiated Logout Request post_logout_redirect_uri parameter
"""

if not post_logout_redirect_uri:
return

if not application:
raise InvalidOIDCClientError()
scheme = urlparse(post_logout_redirect_uri)[0]
if not scheme:
raise InvalidOIDCRedirectURIError("A Scheme is required for the redirect URI.")
if oauth2_settings.OIDC_RP_INITIATED_LOGOUT_STRICT_REDIRECT_URIS and (
scheme == "http" and application.client_type != "confidential"
):
raise InvalidOIDCRedirectURIError("http is only allowed with confidential clients.")
if scheme not in application.get_allowed_schemes():
raise InvalidOIDCRedirectURIError(f'Redirect to scheme "{scheme}" is not permitted.')
if not application.post_logout_redirect_uri_allowed(post_logout_redirect_uri):
raise InvalidOIDCRedirectURIError("This client does not have this redirect uri registered.")

def validate_logout_request_user(self, id_token_hint, client_id):
"""
Validate the an OIDC RP-Initiated Logout Request user
"""

if not id_token_hint:
return

# Only basic validation has been done on the IDToken at this point.
id_token, claims = _load_id_token(id_token_hint)

if not id_token or not _validate_claims(self.request, claims):
raise InvalidIDTokenError()

# If both id_token_hint and client_id are given it must be verified that they match.
if client_id:
if id_token.application.client_id != client_id:
raise ClientIdMissmatch()

return id_token

def get_request_application(self, id_token, client_id):
if client_id:
return get_application_model().objects.get(client_id=client_id)
if id_token:
return id_token.application

def validate_logout_request(self, id_token_hint, client_id, post_logout_redirect_uri):
"""
Validate an OIDC RP-Initiated Logout Request.
`(application, token_user)` is returned.
If it is set, `application` is the Application that is requesting the logout.
`token_user` is the id_token user, which will used to revoke the tokens if found.
The `id_token_hint` will be validated if given. If both `client_id` and `id_token_hint` are given they
will be validated against each other.
"""

id_token = self.validate_logout_request_user(id_token_hint, client_id)
application = self.get_request_application(id_token, client_id)
self.validate_post_logout_redirect_uri(application, post_logout_redirect_uri)

return application, id_token.user if id_token else None

def must_prompt(self, token_user):
"""Indicate whether the logout has to be confirmed by the user. This happens if the
specifications force a confirmation, or it is enabled by `OIDC_RP_INITIATED_LOGOUT_ALWAYS_PROMPT`.
A logout without user interaction (i.e. no prompt) is only allowed
if an ID Token is provided that matches the current user.
"""
return (
oauth2_settings.OIDC_RP_INITIATED_LOGOUT_ALWAYS_PROMPT
or token_user is None
or token_user != self.request.user
)

def do_logout(self, application=None, post_logout_redirect_uri=None, state=None, token_user=None):
user = token_user or self.request.user
# Delete Access Tokens if a user was found
Expand Down
89 changes: 87 additions & 2 deletions tests/test_oidc_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,12 @@
from oauth2_provider.models import get_access_token_model, get_id_token_model, get_refresh_token_model
from oauth2_provider.oauth2_validators import OAuth2Validator
from oauth2_provider.settings import oauth2_settings
from oauth2_provider.views.oidc import _load_id_token, _validate_claims, validate_logout_request
from oauth2_provider.views.oidc import (
RPInitiatedLogoutView,
_load_id_token,
_validate_claims,
validate_logout_request,
)

from . import presets

Expand Down Expand Up @@ -187,7 +192,9 @@ def mock_request_for(user):

@pytest.mark.django_db
@pytest.mark.parametrize("ALWAYS_PROMPT", [True, False])
def test_validate_logout_request(oidc_tokens, public_application, other_user, rp_settings, ALWAYS_PROMPT):
def test_deprecated_validate_logout_request(
oidc_tokens, public_application, other_user, rp_settings, ALWAYS_PROMPT
):
rp_settings.OIDC_RP_INITIATED_LOGOUT_ALWAYS_PROMPT = ALWAYS_PROMPT
oidc_tokens = oidc_tokens
application = oidc_tokens.application
Expand Down Expand Up @@ -266,6 +273,84 @@ def test_validate_logout_request(oidc_tokens, public_application, other_user, rp
)


@pytest.mark.django_db
def test_validate_logout_request(oidc_tokens, public_application):
oidc_tokens = oidc_tokens
application = oidc_tokens.application
client_id = application.client_id
id_token = oidc_tokens.id_token
view = RPInitiatedLogoutView()
view.request = mock_request_for(oidc_tokens.user)
assert view.validate_logout_request(
id_token_hint=None,
client_id=None,
post_logout_redirect_uri=None,
) == (None, None)
assert view.validate_logout_request(
id_token_hint=None,
client_id=client_id,
post_logout_redirect_uri=None,
) == (application, None)
assert view.validate_logout_request(
id_token_hint=None,
client_id=client_id,
post_logout_redirect_uri="http://example.org",
) == (application, None)
assert view.validate_logout_request(
id_token_hint=id_token,
client_id=None,
post_logout_redirect_uri="http://example.org",
) == (application, oidc_tokens.user)
assert view.validate_logout_request(
id_token_hint=id_token,
client_id=client_id,
post_logout_redirect_uri="http://example.org",
) == (application, oidc_tokens.user)
with pytest.raises(ClientIdMissmatch):
view.validate_logout_request(
id_token_hint=id_token,
client_id=public_application.client_id,
post_logout_redirect_uri="http://other.org",
)
with pytest.raises(InvalidOIDCClientError):
view.validate_logout_request(
id_token_hint=None,
client_id=None,
post_logout_redirect_uri="http://example.org",
)
with pytest.raises(InvalidOIDCRedirectURIError):
view.validate_logout_request(
id_token_hint=None,
client_id=client_id,
post_logout_redirect_uri="example.org",
)
with pytest.raises(InvalidOIDCRedirectURIError):
view.validate_logout_request(
id_token_hint=None,
client_id=client_id,
post_logout_redirect_uri="imap://example.org",
)
with pytest.raises(InvalidOIDCRedirectURIError):
view.validate_logout_request(
id_token_hint=None,
client_id=client_id,
post_logout_redirect_uri="http://other.org",
)


@pytest.mark.django_db
@pytest.mark.parametrize("ALWAYS_PROMPT", [True, False])
def test_must_prompt(oidc_tokens, other_user, rp_settings, ALWAYS_PROMPT):
rp_settings.OIDC_RP_INITIATED_LOGOUT_ALWAYS_PROMPT = ALWAYS_PROMPT
oidc_tokens = oidc_tokens
assert RPInitiatedLogoutView(request=mock_request_for(oidc_tokens.user)).must_prompt(None) is True
assert (
RPInitiatedLogoutView(request=mock_request_for(oidc_tokens.user)).must_prompt(oidc_tokens.user)
== ALWAYS_PROMPT
)
assert RPInitiatedLogoutView(request=mock_request_for(other_user)).must_prompt(oidc_tokens.user) is True


def test__load_id_token():
assert _load_id_token("Not a Valid ID Token.") == (None, None)

Expand Down

0 comments on commit e4b06eb

Please sign in to comment.