Skip to content
Merged
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
1 change: 1 addition & 0 deletions AUTHORS
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ Allisson Azevedo
Andrea Greco
Andrej Zbín
Andrew Chen Wang
Antoine Laurent
Anvesh Agarwal
Aristóbulo Meneses
Aryan Iyappan
Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
* #1211 documentation improve on 'AUTHORIZATION_CODE_EXPIRE_SECONDS'.
* #1218 Confim support for Python 3.11.
* #1222 Remove expired ID tokens alongside access tokens in `cleartokens` management command
* #1270 Fix RP-initiated Logout with no available Django session

## [2.2.0] 2022-10-18

Expand Down
22 changes: 13 additions & 9 deletions oauth2_provider/views/oidc.py
Original file line number Diff line number Diff line change
Expand Up @@ -210,27 +210,31 @@ def _validate_claims(request, claims):
def validate_logout_request(request, id_token_hint, client_id, post_logout_redirect_uri):
"""
Validate an OIDC RP-Initiated Logout Request.
`(prompt_logout, (post_logout_redirect_uri, application))` is returned.
`(prompt_logout, (post_logout_redirect_uri, application), token_user)` is returned.

`prompt_logout` indicates 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`.
`post_logout_redirect_uri` is the validated URI where the User should be redirected to after the
logout. Can be None. None will redirect to "/" of this app. If it is set `application` will also
be set to the Application that is requesting the logout.
be set to 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 = None
must_prompt_logout = True
token_user = None
if id_token_hint:
# 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(request, claims):
raise InvalidIDTokenError()

token_user = id_token.user

if id_token.user == request.user:
# A logout without user interaction (i.e. no prompt) is only allowed
# if an ID Token is provided that matches the current user.
Expand Down Expand Up @@ -268,7 +272,7 @@ def validate_logout_request(request, id_token_hint, client_id, post_logout_redir
if not application.post_logout_redirect_uri_allowed(post_logout_redirect_uri):
raise InvalidOIDCRedirectURIError("This client does not have this redirect uri registered.")

return prompt_logout, (post_logout_redirect_uri, application)
return prompt_logout, (post_logout_redirect_uri, application), token_user


class RPInitiatedLogoutView(OIDCLogoutOnlyMixin, FormView):
Expand Down Expand Up @@ -309,7 +313,7 @@ def get(self, request, *args, **kwargs):
state = request.GET.get("state")

try:
prompt, (redirect_uri, application) = validate_logout_request(
prompt, (redirect_uri, application), token_user = validate_logout_request(
request=request,
id_token_hint=id_token_hint,
client_id=client_id,
Expand All @@ -319,7 +323,7 @@ def get(self, request, *args, **kwargs):
return self.error_response(error)

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

self.oidc_data = {
"id_token_hint": id_token_hint,
Expand All @@ -341,28 +345,28 @@ def form_valid(self, form):
state = form.cleaned_data.get("state")

try:
prompt, (redirect_uri, application) = validate_logout_request(
prompt, (redirect_uri, application), token_user = validate_logout_request(
request=self.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)
return self.do_logout(application, redirect_uri, state, token_user)
else:
raise LogoutDenied()

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

def do_logout(self, application=None, post_logout_redirect_uri=None, state=None):
def do_logout(self, application=None, post_logout_redirect_uri=None, state=None, token_user=None):
# Delete Access Tokens
if oauth2_settings.OIDC_RP_INITIATED_LOGOUT_DELETE_TOKENS:
AccessToken = get_access_token_model()
RefreshToken = get_refresh_token_model()
access_tokens_to_delete = AccessToken.objects.filter(
user=self.request.user,
user=token_user or self.request.user,
application__client_type__in=self.token_deletion_client_types,
application__authorization_grant_type__in=self.token_deletion_grant_types,
)
Expand Down
54 changes: 48 additions & 6 deletions tests/test_oidc_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from django.test import RequestFactory, TestCase
from django.urls import reverse
from django.utils import timezone
from pytest_django.asserts import assertRedirects

from oauth2_provider.exceptions import ClientIdMissmatch, InvalidOIDCClientError, InvalidOIDCRedirectURIError
from oauth2_provider.models import get_access_token_model, get_id_token_model, get_refresh_token_model
Expand Down Expand Up @@ -197,37 +198,37 @@ def test_validate_logout_request(oidc_tokens, public_application, other_user, rp
id_token_hint=None,
client_id=None,
post_logout_redirect_uri=None,
) == (True, (None, None))
) == (True, (None, None), None)
assert validate_logout_request(
request=mock_request_for(oidc_tokens.user),
id_token_hint=None,
client_id=client_id,
post_logout_redirect_uri=None,
) == (True, (None, application))
) == (True, (None, application), None)
assert validate_logout_request(
request=mock_request_for(oidc_tokens.user),
id_token_hint=None,
client_id=client_id,
post_logout_redirect_uri="http://example.org",
) == (True, ("http://example.org", application))
) == (True, ("http://example.org", application), None)
assert validate_logout_request(
request=mock_request_for(oidc_tokens.user),
id_token_hint=id_token,
client_id=None,
post_logout_redirect_uri="http://example.org",
) == (ALWAYS_PROMPT, ("http://example.org", application))
) == (ALWAYS_PROMPT, ("http://example.org", application), oidc_tokens.user)
assert validate_logout_request(
request=mock_request_for(other_user),
id_token_hint=id_token,
client_id=None,
post_logout_redirect_uri="http://example.org",
) == (True, ("http://example.org", application))
) == (True, ("http://example.org", application), oidc_tokens.user)
assert validate_logout_request(
request=mock_request_for(oidc_tokens.user),
id_token_hint=id_token,
client_id=client_id,
post_logout_redirect_uri="http://example.org",
) == (ALWAYS_PROMPT, ("http://example.org", application))
) == (ALWAYS_PROMPT, ("http://example.org", application), oidc_tokens.user)
with pytest.raises(ClientIdMissmatch):
validate_logout_request(
request=mock_request_for(oidc_tokens.user),
Expand Down Expand Up @@ -519,6 +520,47 @@ def test_token_deletion_on_logout(oidc_tokens, loggend_in_client, rp_settings):
assert all([token.revoked <= timezone.now() for token in RefreshToken.objects.all()])


@pytest.mark.django_db
def test_token_deletion_on_logout_expired_session(oidc_tokens, client, rp_settings):
AccessToken = get_access_token_model()
IDToken = get_id_token_model()
RefreshToken = get_refresh_token_model()
assert AccessToken.objects.count() == 1
assert IDToken.objects.count() == 1
assert RefreshToken.objects.count() == 1
rsp = client.get(
reverse("oauth2_provider:rp-initiated-logout"),
data={
"id_token_hint": oidc_tokens.id_token,
"client_id": oidc_tokens.application.client_id,
},
)
assert rsp.status_code == 200
assert not is_logged_in(client)
# Check that all tokens are active.
access_token = AccessToken.objects.get()
assert not access_token.is_expired()
id_token = IDToken.objects.get()
assert not id_token.is_expired()
refresh_token = RefreshToken.objects.get()
assert refresh_token.revoked is None

rsp = client.post(
reverse("oauth2_provider:rp-initiated-logout"),
data={
"id_token_hint": oidc_tokens.id_token,
"client_id": oidc_tokens.application.client_id,
"allow": True,
},
)
assertRedirects(rsp, "http://testserver/", fetch_redirect_response=False)
assert not is_logged_in(client)
# Check that all tokens have either been deleted or expired.
assert all(token.is_expired() for token in AccessToken.objects.all())
assert all(token.is_expired() for token in IDToken.objects.all())
assert all(token.revoked <= timezone.now() for token in RefreshToken.objects.all())


@pytest.mark.django_db
@pytest.mark.oauth2_settings(presets.OIDC_SETTINGS_RP_LOGOUT_KEEP_TOKENS)
def test_token_deletion_on_logout_disabled(oidc_tokens, loggend_in_client, rp_settings):
Expand Down