Skip to content

Commit a981a3d

Browse files
committed
Work on end_session_endpoint
1 parent 7086a49 commit a981a3d

File tree

6 files changed

+268
-86
lines changed

6 files changed

+268
-86
lines changed

docs/sections/changelog.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ All notable changes to this project will be documented in this file.
88
Unreleased
99
==========
1010

11+
* Changed: Improved "OpenID Connect RP-Initiated Logout" implementation.
1112
* Fixed: RSA server keys random ordering.
1213
* Fixed: Example app working with Django 4.
1314

docs/sections/sessionmanagement.rst

Lines changed: 33 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,39 @@ Somewhere in your Django ``settings.py``::
2222
If you're in a multi-server setup, you might also want to add ``OIDC_UNAUTHENTICATED_SESSION_MANAGEMENT_KEY`` to your settings and set it to some random but fixed string. While authenticated clients have a session that can be used to calculate the browser state, there is no such thing for unauthenticated clients. Hence this value. By default a value is generated randomly on startup, so this will be different on each server. To get a consistent value across all servers you should set this yourself.
2323

2424

25+
RP-Initiated Logout
26+
===================
27+
28+
An RP can notify the OP that the End-User has logged out of the site, and might want to log out of the OP as well. In this case, the RP, after having logged the End-User out of the RP, redirects the End-User's User Agent to the OP's logout endpoint URL.
29+
30+
This URL is normally obtained via the ``end_session_endpoint`` element of the OP's Discovery response.
31+
32+
Parameters that are passed as query parameters in the logout request:
33+
34+
* ``id_token_hint``
35+
RECOMMENDED. Previously issued ID Token passed to the logout endpoint as a hint about the End-User's current authenticated session with the Client.
36+
* ``post_logout_redirect_uri``
37+
OPTIONAL. URL to which the RP is requesting that the End-User's User Agent be redirected after a logout has been performed.
38+
39+
The value must be a valid, encoded URL that has been registered in the list of "Post Logout Redirect URIs" in your Client (RP) page.
40+
* ``state``
41+
OPTIONAL. Opaque value used by the RP to maintain state between the logout request and the callback to the endpoint specified by the ``post_logout_redirect_uri`` query parameter.
42+
43+
Example redirect::
44+
45+
http://localhost:8000/end-session/?id_token_hint=eyJhbGciOiJSUzI1NiIsImtpZCI6ImQwM...&post_logout_redirect_uri=http://rp.example.com/logged-out/&state=c91c03ea6c46a86
46+
47+
**Logout consent prompt**
48+
49+
The standard defines that the logout flow should be interrupted to prompt the user for consent if the OpenID provider cannot verify that the request was made by the user.
50+
51+
We enforce this behavior by displaying a logout consent prompt if it detects any of the following conditions:
52+
53+
* If ``id_token_hint`` is not present or is invalid (we could not validate the client from it).
54+
* If ``post_logout_redirect_uri`` is not registered in the list of "Post Logout Redirect URIs".
55+
56+
If the user confirms the logout request, we continue the logout flow. To modify the logout consent template create your own ``oidc_provider/end_session_prompt.html``.
57+
2558
Example RP iframe
2659
=================
2760

@@ -70,22 +103,4 @@ Example RP iframe
70103
</script>
71104
</html>
72105

73-
RP-Initiated Logout
74-
===================
75-
76-
An RP can notify the OP that the End-User has logged out of the site, and might want to log out of the OP as well. In this case, the RP, after having logged the End-User out of the RP, redirects the End-User's User Agent to the OP's logout endpoint URL.
77106

78-
This URL is normally obtained via the ``end_session_endpoint`` element of the OP's Discovery response.
79-
80-
Parameters that are passed as query parameters in the logout request:
81-
82-
* ``id_token_hint``
83-
Previously issued ID Token passed to the logout endpoint as a hint about the End-User's current authenticated session with the Client.
84-
* ``post_logout_redirect_uri``
85-
URL to which the RP is requesting that the End-User's User Agent be redirected after a logout has been performed.
86-
* ``state``
87-
OPTIONAL. Opaque value used by the RP to maintain state between the logout request and the callback to the endpoint specified by the ``post_logout_redirect_uri`` query parameter.
88-
89-
Example redirect::
90-
91-
http://localhost:8000/end-session/?id_token_hint=eyJhbGciOiJSUzI1NiIsImtpZCI6ImQwM...&post_logout_redirect_uri=http://rp.example.com/logged-out/&state=c91c03ea6c46a86
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
<h1>End Session</h1>
2+
3+
<p>Hi <strong>{{ user.email }}</strong>, are you sure you want to log out{% if client %} from <strong>{{ client.name }}</strong> app{% endif %}?</p>
4+
5+
<form method="post" action="{{ end_session_prompt_url }}">
6+
7+
{% csrf_token %}
8+
9+
<input type="submit" value="Cancel" />
10+
<input type="submit" name="allow" value="Yes" />
11+
12+
</form>
Lines changed: 125 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,8 @@
1+
try:
2+
from urllib import urlencode
3+
except ImportError:
4+
from urllib.parse import urlencode
5+
16
from django.core.management import call_command
27
try:
38
from django.urls import reverse
@@ -26,55 +31,139 @@ class EndSessionTestCase(TestCase):
2631
def setUp(self):
2732
call_command('creatersakey')
2833
self.user = create_fake_user()
34+
self.client.force_login(self.user)
2935

36+
# Create a client with a custom logout URL.
3037
self.oidc_client = create_fake_client('id_token')
31-
self.LOGOUT_URL = 'http://example.com/logged-out/'
32-
self.oidc_client.post_logout_redirect_uris = [self.LOGOUT_URL]
38+
self.url_logout = 'http://example.com/logged-out/'
39+
self.oidc_client.post_logout_redirect_uris = [self.url_logout]
3340
self.oidc_client.save()
3441

35-
self.url = reverse('oidc_provider:end-session')
36-
37-
def test_redirects_when_aud_is_str(self):
38-
query_params = {
39-
'post_logout_redirect_uri': self.LOGOUT_URL,
40-
}
41-
response = self.client.get(self.url, query_params)
42-
# With no id_token the OP MUST NOT redirect to the requested
43-
# redirect_uri.
44-
self.assertRedirects(
45-
response, settings.get('OIDC_LOGIN_URL'),
46-
fetch_redirect_response=False)
47-
42+
# Create a valid ID Token for the user.
4843
token = create_token(self.user, self.oidc_client, [])
4944
id_token_dic = create_id_token(
5045
token=token, user=self.user, aud=self.oidc_client.client_id)
51-
id_token = encode_id_token(id_token_dic, self.oidc_client)
46+
self.id_token = encode_id_token(id_token_dic, self.oidc_client)
5247

53-
query_params['id_token_hint'] = id_token
48+
self.url = reverse('oidc_provider:end-session')
49+
self.url_prompt = reverse('oidc_provider:end-session-prompt')
5450

51+
def test_id_token_hint_not_present_user_prompted(self):
52+
response = self.client.get(self.url)
53+
# We should display a logout consent prompt if id_token_hint parameter is not present.
54+
self.assertEqual(response.status_code, 302)
55+
self.assertEqual(response.headers['Location'], self.url_prompt)
56+
57+
def test_id_token_hint_is_present_user_redirected_to_client_logout_url(self):
58+
query_params = {
59+
'id_token_hint': self.id_token,
60+
}
5561
response = self.client.get(self.url, query_params)
56-
self.assertRedirects(
57-
response, self.LOGOUT_URL, fetch_redirect_response=False)
62+
# ID Token is valid so user was
63+
self.assertEqual(response.status_code, 302)
64+
self.assertEqual(response.headers['Location'], self.url_logout)
65+
66+
def test_id_token_hint_is_present_user_redirected_to_client_logout_url_with_post(self):
67+
data = {
68+
'id_token_hint': self.id_token,
69+
}
70+
response = self.client.post(self.url, data)
71+
# ID Token is valid so user was
72+
self.assertEqual(response.status_code, 302)
73+
self.assertEqual(response.headers['Location'], self.url_logout)
5874

59-
def test_redirects_when_aud_is_list(self):
60-
"""Check with 'aud' containing a list of str."""
75+
def test_state_is_present_and_being_passed_to_logout_url(self):
6176
query_params = {
62-
'post_logout_redirect_uri': self.LOGOUT_URL,
77+
'id_token_hint': self.id_token,
78+
'state': 'ABCDE',
6379
}
64-
token = create_token(self.user, self.oidc_client, [])
65-
id_token_dic = create_id_token(
66-
token=token, user=self.user, aud=self.oidc_client.client_id)
67-
id_token_dic['aud'] = [id_token_dic['aud']]
68-
id_token = encode_id_token(id_token_dic, self.oidc_client)
69-
query_params['id_token_hint'] = id_token
7080
response = self.client.get(self.url, query_params)
71-
self.assertRedirects(
72-
response, self.LOGOUT_URL, fetch_redirect_response=False)
81+
# Let's ensure state is being passed to the logout url.
82+
self.assertEqual(response.status_code, 302)
83+
self.assertEqual(response.headers['Location'], '{0}?state={1}'.format(self.url_logout, 'ABCDE'))
84+
85+
def test_post_logout_uri_not_in_client_urls(self):
86+
query_params = {
87+
'id_token_hint': self.id_token,
88+
'post_logout_redirect_uri': 'http://other.com/bye/',
89+
}
90+
response = self.client.get(self.url, query_params)
91+
# We prompt the user since the post logout url is not from client urls.
92+
# Also ensure client_id is present since we could validate id_token_hint.
93+
self.assertEqual(response.status_code, 302)
94+
self.assertEqual(response.headers['Location'], '{0}?client_id={1}'.format(self.url_prompt, self.oidc_client.client_id))
95+
96+
def test_prompt_view_redirecting_to_client_post_logout_since_user_unauthenticated(self):
97+
self.client.logout()
98+
query_params = {
99+
'client_id': self.oidc_client.client_id,
100+
}
101+
response = self.client.get(self.url_prompt, query_params)
102+
# Since user is unauthenticated on the backend, we send it back to client post logout
103+
# registered uri.
104+
self.assertEqual(response.status_code, 302)
105+
self.assertEqual(response.headers['Location'], self.url_logout)
106+
107+
def test_prompt_view_raising_404_since_user_unauthenticated_and_no_client(self):
108+
self.client.logout()
109+
response = self.client.get(self.url_prompt)
110+
# Since user is unauthenticated and no client information is present, we just show
111+
# not found page.
112+
self.assertEqual(response.status_code, 404)
113+
114+
def test_prompt_view_displaying_logout_decision_form_to_user(self):
115+
query_params = {
116+
'client_id': self.oidc_client.client_id,
117+
}
118+
response = self.client.get(self.url_prompt, query_params)
119+
# User is prompted to logout with client information displayed.
120+
self.assertContains(response, '<p>Hi <strong>johndoe@example.com</strong>, are you sure you want to log out from <strong>Some Client</strong> app?</p>', status_code=200, html=True)
73121

122+
def test_prompt_view_displaying_logout_decision_form_to_user_no_client(self):
123+
response = self.client.get(self.url_prompt)
124+
# User is prompted to logout without client information displayed.
125+
self.assertContains(response, '<p>Hi <strong>johndoe@example.com</strong>, are you sure you want to log out?</p>', status_code=200, html=True)
126+
127+
@mock.patch(settings.get('OIDC_AFTER_END_SESSION_HOOK'))
128+
def test_prompt_view_user_logged_out_after_form_allowed(self, end_session_hook):
129+
self.assertIn('_auth_user_id', self.client.session)
130+
# We want to POST to /end-session-prompt/?client_id=ABC endpoint.
131+
url_prompt_with_client = self.url_prompt + '?' + urlencode({
132+
'client_id': self.oidc_client.client_id,
133+
})
134+
data = {
135+
'allow': 'Anything', # This means user allowed being logged out.
136+
}
137+
response = self.client.post(url_prompt_with_client, data)
138+
# Ensure user is now logged out and redirected to client post logout uri.
139+
self.assertNotIn('_auth_user_id', self.client.session)
140+
self.assertEqual(response.status_code, 302)
141+
self.assertEqual(response.headers['Location'], self.url_logout)
142+
# End session hook should be called.
143+
self.assertTrue(end_session_hook.called)
144+
self.assertTrue(end_session_hook.call_count == 1)
145+
146+
@mock.patch(settings.get('OIDC_AFTER_END_SESSION_HOOK'))
147+
def test_prompt_view_user_logged_out_after_form_not_allowed(self, end_session_hook):
148+
self.assertIn('_auth_user_id', self.client.session)
149+
# We want to POST to /end-session-prompt/?client_id=ABC endpoint.
150+
url_prompt_with_client = self.url_prompt + '?' + urlencode({
151+
'client_id': self.oidc_client.client_id,
152+
})
153+
response = self.client.post(url_prompt_with_client) # No data.
154+
# Ensure user is still logged in and redirected to client post logout uri.
155+
self.assertIn('_auth_user_id', self.client.session)
156+
self.assertEqual(response.status_code, 302)
157+
self.assertEqual(response.headers['Location'], self.url_logout)
158+
# End session hook should not be called.
159+
self.assertFalse(end_session_hook.called)
160+
74161
@mock.patch(settings.get('OIDC_AFTER_END_SESSION_HOOK'))
75-
def test_call_post_end_session_hook(self, hook_function):
76-
self.client.get(self.url)
77-
self.assertTrue(hook_function.called, 'OIDC_AFTER_END_SESSION_HOOK should be called')
78-
self.assertTrue(
79-
hook_function.call_count == 1,
80-
'OIDC_AFTER_END_SESSION_HOOK should be called once')
162+
def test_prompt_view_user_not_logged_out_after_form_not_allowed_no_client(self, end_session_hook):
163+
self.assertIn('_auth_user_id', self.client.session)
164+
response = self.client.post(self.url_prompt) # No data.
165+
# Ensure user is still logged in and 404 NOT FOUND was raised.
166+
self.assertIn('_auth_user_id', self.client.session)
167+
self.assertEqual(response.status_code, 404)
168+
# End session hook should not be called.
169+
self.assertFalse(end_session_hook.called)

oidc_provider/urls.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
re_path(r'^token/?$', csrf_exempt(views.TokenView.as_view()), name='token'),
1313
re_path(r'^userinfo/?$', csrf_exempt(views.userinfo), name='userinfo'),
1414
re_path(r'^end-session/?$', views.EndSessionView.as_view(), name='end-session'),
15+
re_path(r'^end-session-prompt/?$', views.EndSessionPromptView.as_view(), name='end-session-prompt'),
1516
re_path(r'^\.well-known/openid-configuration/?$', views.ProviderInfoView.as_view(),
1617
name='provider-info'),
1718
re_path(r'^introspect/?$', views.TokenIntrospectionView.as_view(), name='token-introspection'),

0 commit comments

Comments
 (0)