Skip to content

Commit 812bec0

Browse files
committed
Send only one logout request per application, per user
1 parent 6483265 commit 812bec0

File tree

2 files changed

+124
-8
lines changed

2 files changed

+124
-8
lines changed

oauth2_provider/handlers.py

Lines changed: 21 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,12 @@
99
from jwcrypto import jwt
1010

1111
from .exceptions import BackchannelLogoutRequestError
12-
from .models import AbstractApplication, get_id_token_model
12+
from .models import AbstractApplication, get_id_token_model, get_refresh_token_model
1313
from .settings import oauth2_settings
1414

1515

1616
IDToken = get_id_token_model()
17+
RefreshToken = get_refresh_token_model()
1718

1819
logger = logging.getLogger(__name__)
1920

@@ -76,9 +77,22 @@ def on_user_logged_out_maybe_send_backchannel_logout(sender, **kwargs):
7677
return
7778

7879
user = kwargs["user"]
79-
id_tokens = IDToken.objects.filter(application__backchannel_logout_uri__isnull=False, user=user)
80-
for id_token in id_tokens:
81-
try:
82-
handler(id_token=id_token)
83-
except BackchannelLogoutRequestError as exc:
84-
logger.warn(str(exc))
80+
# Get refresh tokens for user where scope doesn't contain offline_access
81+
refresh_tokens = (
82+
RefreshToken.objects.filter(user=user, access_token__scope__isnull=False)
83+
.exclude(access_token__scope__icontains="offline_access")
84+
.select_related("application", "access_token")
85+
)
86+
87+
# Group by application and send one request per application
88+
applications_to_logout = set()
89+
for rt in refresh_tokens:
90+
if rt.application.backchannel_logout_uri and rt.application not in applications_to_logout:
91+
applications_to_logout.add(rt.application)
92+
# Find an ID token for this user/application to use for logout
93+
id_token = IDToken.objects.filter(application=rt.application, user=user).first()
94+
if id_token:
95+
try:
96+
handler(id_token=id_token)
97+
except BackchannelLogoutRequestError as exc:
98+
logger.warn(str(exc))

tests/test_backchannel_logout.py

Lines changed: 103 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,12 @@
88
from django.urls import reverse
99

1010
from oauth2_provider.exceptions import BackchannelLogoutRequestError
11-
from oauth2_provider.models import get_application_model, get_id_token_model
11+
from oauth2_provider.models import (
12+
get_application_model,
13+
get_id_token_model,
14+
get_access_token_model,
15+
get_refresh_token_model,
16+
)
1217
from oauth2_provider.handlers import (
1318
on_user_logged_out_maybe_send_backchannel_logout,
1419
send_backchannel_logout_request,
@@ -21,6 +26,8 @@
2126

2227
Application = get_application_model()
2328
IDToken = get_id_token_model()
29+
AccessToken = get_access_token_model()
30+
RefreshToken = get_refresh_token_model()
2431
User = get_user_model()
2532

2633

@@ -45,6 +52,20 @@ def setUp(self):
4552
application=self.application, user=self.user, expires=expiration_date
4653
)
4754

55+
self.access_token = AccessToken.objects.create(
56+
user=self.user,
57+
application=self.application,
58+
token="test_access_token",
59+
expires=now + datetime.timedelta(hours=1),
60+
scope="read write", # No offline_access scope
61+
)
62+
self.refresh_token = RefreshToken.objects.create(
63+
user=self.user,
64+
application=self.application,
65+
token="test_refresh_token",
66+
access_token=self.access_token,
67+
)
68+
4869
def test_on_logout_handler_is_called_for_user(self):
4970
with patch("oauth2_provider.handlers.send_backchannel_logout_request") as backchannel_handler:
5071
self.client.login(username="app_user", password="654321")
@@ -78,3 +99,84 @@ def test_logout_sender_does_not_crash_on_backchannel_error(self):
7899
with patch("oauth2_provider.handlers.send_backchannel_logout_request") as mock_func:
79100
mock_func.side_effect = BackchannelLogoutRequestError("Bad Gateway")
80101
on_user_logged_out_maybe_send_backchannel_logout(sender=User, user=self.user)
102+
103+
def test_no_logout_sent_when_only_offline_access_refresh_tokens(self):
104+
# Add offline_access scope
105+
self.access_token.scope = "read write offline_access"
106+
self.access_token.save()
107+
108+
with patch("oauth2_provider.handlers.send_backchannel_logout_request") as backchannel_handler:
109+
on_user_logged_out_maybe_send_backchannel_logout(sender=User, user=self.user)
110+
backchannel_handler.assert_not_called()
111+
112+
def test_logout_sent_when_refresh_token_without_offline_access(self):
113+
with patch("oauth2_provider.handlers.send_backchannel_logout_request") as backchannel_handler:
114+
on_user_logged_out_maybe_send_backchannel_logout(sender=User, user=self.user)
115+
backchannel_handler.assert_called_once()
116+
_, kwargs = backchannel_handler.call_args
117+
self.assertEqual(kwargs["id_token"], self.id_token)
118+
119+
def test_only_one_logout_per_application_with_multiple_refresh_tokens(self):
120+
# Create another refresh token for the same application
121+
now = timezone.now()
122+
another_access_token = AccessToken.objects.create(
123+
user=self.user,
124+
application=self.application,
125+
token="test_access_token_2",
126+
expires=now + datetime.timedelta(hours=1),
127+
scope="read write",
128+
)
129+
RefreshToken.objects.create(
130+
user=self.user,
131+
application=self.application,
132+
token="test_refresh_token_2",
133+
access_token=another_access_token,
134+
)
135+
136+
# Should still be called only once despite having 2 refresh tokens
137+
with patch("oauth2_provider.handlers.send_backchannel_logout_request") as backchannel_handler:
138+
on_user_logged_out_maybe_send_backchannel_logout(sender=User, user=self.user)
139+
backchannel_handler.assert_called_once()
140+
141+
def test_logout_sent_for_multiple_applications(self):
142+
# Create another application with backchannel logout URI
143+
another_app = Application.objects.create(
144+
name="test_app_2",
145+
user=self.developer,
146+
client_type=Application.CLIENT_PUBLIC,
147+
authorization_grant_type=Application.GRANT_CLIENT_CREDENTIALS,
148+
algorithm=Application.RS256_ALGORITHM,
149+
client_secret="another_secret",
150+
backchannel_logout_uri="http://rp2.example.com/logout",
151+
)
152+
153+
# Create ID token for the second application
154+
another_id_token = IDToken.objects.create(
155+
application=another_app, user=self.user, expires=timezone.now() + datetime.timedelta(minutes=180)
156+
)
157+
158+
# Create access token and refresh token for the second application
159+
now = timezone.now()
160+
another_access_token = AccessToken.objects.create(
161+
user=self.user,
162+
application=another_app,
163+
token="test_access_token_app2",
164+
expires=now + datetime.timedelta(hours=1),
165+
scope="read write",
166+
)
167+
RefreshToken.objects.create(
168+
user=self.user,
169+
application=another_app,
170+
token="test_refresh_token_app2",
171+
access_token=another_access_token,
172+
)
173+
174+
# Should be called twice - once for each application - and both ID tokens were used
175+
with patch("oauth2_provider.handlers.send_backchannel_logout_request") as backchannel_handler:
176+
on_user_logged_out_maybe_send_backchannel_logout(sender=User, user=self.user)
177+
self.assertEqual(backchannel_handler.call_count, 2)
178+
179+
call_args_list = backchannel_handler.call_args_list
180+
id_tokens_called = [call.kwargs["id_token"] for call in call_args_list]
181+
self.assertIn(self.id_token, id_tokens_called)
182+
self.assertIn(another_id_token, id_tokens_called)

0 commit comments

Comments
 (0)