diff --git a/docs/sections/changelog.rst b/docs/sections/changelog.rst index 73a19aa4..31ebcf67 100644 --- a/docs/sections/changelog.rst +++ b/docs/sections/changelog.rst @@ -8,6 +8,7 @@ All notable changes to this project will be documented in this file. Unreleased ========== +* Changed: Improved "OpenID Connect RP-Initiated Logout" implementation. * Fixed: RSA server keys random ordering. * Fixed: Example app working with Django 4. diff --git a/docs/sections/sessionmanagement.rst b/docs/sections/sessionmanagement.rst index 3c182b71..3ae7e9ac 100644 --- a/docs/sections/sessionmanagement.rst +++ b/docs/sections/sessionmanagement.rst @@ -22,6 +22,39 @@ Somewhere in your Django ``settings.py``:: 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. +RP-Initiated Logout +=================== + +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. + +This URL is normally obtained via the ``end_session_endpoint`` element of the OP's Discovery response. + +Parameters that are passed as query parameters in the logout request: + +* ``id_token_hint`` + RECOMMENDED. Previously issued ID Token passed to the logout endpoint as a hint about the End-User's current authenticated session with the Client. +* ``post_logout_redirect_uri`` + OPTIONAL. URL to which the RP is requesting that the End-User's User Agent be redirected after a logout has been performed. + + 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. +* ``state`` + 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. + +Example redirect:: + + http://localhost:8000/end-session/?id_token_hint=eyJhbGciOiJSUzI1NiIsImtpZCI6ImQwM...&post_logout_redirect_uri=http://rp.example.com/logged-out/&state=c91c03ea6c46a86 + +**Logout consent prompt** + +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. + +We enforce this behavior by displaying a logout consent prompt if it detects any of the following conditions: + +* If ``id_token_hint`` is not present or is invalid (we could not validate the client from it). +* If ``post_logout_redirect_uri`` is not registered in the list of "Post Logout Redirect URIs". + +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``. + Example RP iframe ================= @@ -70,22 +103,4 @@ Example RP iframe -RP-Initiated Logout -=================== - -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. -This URL is normally obtained via the ``end_session_endpoint`` element of the OP's Discovery response. - -Parameters that are passed as query parameters in the logout request: - -* ``id_token_hint`` - Previously issued ID Token passed to the logout endpoint as a hint about the End-User's current authenticated session with the Client. -* ``post_logout_redirect_uri`` - URL to which the RP is requesting that the End-User's User Agent be redirected after a logout has been performed. -* ``state`` - 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. - -Example redirect:: - - http://localhost:8000/end-session/?id_token_hint=eyJhbGciOiJSUzI1NiIsImtpZCI6ImQwM...&post_logout_redirect_uri=http://rp.example.com/logged-out/&state=c91c03ea6c46a86 diff --git a/oidc_provider/templates/oidc_provider/end_session_prompt.html b/oidc_provider/templates/oidc_provider/end_session_prompt.html new file mode 100644 index 00000000..6f3c6842 --- /dev/null +++ b/oidc_provider/templates/oidc_provider/end_session_prompt.html @@ -0,0 +1,12 @@ +

End Session

+ +

Hi {{ user.email }}, are you sure you want to log out{% if client %} from {{ client.name }} app{% endif %}?

+ +
+ + {% csrf_token %} + + + + +
diff --git a/oidc_provider/tests/cases/test_end_session_endpoint.py b/oidc_provider/tests/cases/test_end_session_endpoint.py index fb36f8e8..a0f04038 100644 --- a/oidc_provider/tests/cases/test_end_session_endpoint.py +++ b/oidc_provider/tests/cases/test_end_session_endpoint.py @@ -1,3 +1,8 @@ +try: + from urllib import urlencode +except ImportError: + from urllib.parse import urlencode + from django.core.management import call_command try: from django.urls import reverse @@ -26,55 +31,139 @@ class EndSessionTestCase(TestCase): def setUp(self): call_command('creatersakey') self.user = create_fake_user() + self.client.force_login(self.user) + # Create a client with a custom logout URL. self.oidc_client = create_fake_client('id_token') - self.LOGOUT_URL = 'http://example.com/logged-out/' - self.oidc_client.post_logout_redirect_uris = [self.LOGOUT_URL] + self.url_logout = 'http://example.com/logged-out/' + self.oidc_client.post_logout_redirect_uris = [self.url_logout] self.oidc_client.save() - self.url = reverse('oidc_provider:end-session') - - def test_redirects_when_aud_is_str(self): - query_params = { - 'post_logout_redirect_uri': self.LOGOUT_URL, - } - response = self.client.get(self.url, query_params) - # With no id_token the OP MUST NOT redirect to the requested - # redirect_uri. - self.assertRedirects( - response, settings.get('OIDC_LOGIN_URL'), - fetch_redirect_response=False) - + # Create a valid ID Token for the user. token = create_token(self.user, self.oidc_client, []) id_token_dic = create_id_token( token=token, user=self.user, aud=self.oidc_client.client_id) - id_token = encode_id_token(id_token_dic, self.oidc_client) + self.id_token = encode_id_token(id_token_dic, self.oidc_client) - query_params['id_token_hint'] = id_token + self.url = reverse('oidc_provider:end-session') + self.url_prompt = reverse('oidc_provider:end-session-prompt') + def test_id_token_hint_not_present_user_prompted(self): + response = self.client.get(self.url) + # We should display a logout consent prompt if id_token_hint parameter is not present. + self.assertEqual(response.status_code, 302) + self.assertEqual(response.headers['Location'], self.url_prompt) + + def test_id_token_hint_is_present_user_redirected_to_client_logout_url(self): + query_params = { + 'id_token_hint': self.id_token, + } response = self.client.get(self.url, query_params) - self.assertRedirects( - response, self.LOGOUT_URL, fetch_redirect_response=False) + # ID Token is valid so user was + self.assertEqual(response.status_code, 302) + self.assertEqual(response.headers['Location'], self.url_logout) + + def test_id_token_hint_is_present_user_redirected_to_client_logout_url_with_post(self): + data = { + 'id_token_hint': self.id_token, + } + response = self.client.post(self.url, data) + # ID Token is valid so user was + self.assertEqual(response.status_code, 302) + self.assertEqual(response.headers['Location'], self.url_logout) - def test_redirects_when_aud_is_list(self): - """Check with 'aud' containing a list of str.""" + def test_state_is_present_and_being_passed_to_logout_url(self): query_params = { - 'post_logout_redirect_uri': self.LOGOUT_URL, + 'id_token_hint': self.id_token, + 'state': 'ABCDE', } - token = create_token(self.user, self.oidc_client, []) - id_token_dic = create_id_token( - token=token, user=self.user, aud=self.oidc_client.client_id) - id_token_dic['aud'] = [id_token_dic['aud']] - id_token = encode_id_token(id_token_dic, self.oidc_client) - query_params['id_token_hint'] = id_token response = self.client.get(self.url, query_params) - self.assertRedirects( - response, self.LOGOUT_URL, fetch_redirect_response=False) + # Let's ensure state is being passed to the logout url. + self.assertEqual(response.status_code, 302) + self.assertEqual(response.headers['Location'], '{0}?state={1}'.format(self.url_logout, 'ABCDE')) + + def test_post_logout_uri_not_in_client_urls(self): + query_params = { + 'id_token_hint': self.id_token, + 'post_logout_redirect_uri': 'http://other.com/bye/', + } + response = self.client.get(self.url, query_params) + # We prompt the user since the post logout url is not from client urls. + # Also ensure client_id is present since we could validate id_token_hint. + self.assertEqual(response.status_code, 302) + self.assertEqual(response.headers['Location'], '{0}?client_id={1}'.format(self.url_prompt, self.oidc_client.client_id)) + + def test_prompt_view_redirecting_to_client_post_logout_since_user_unauthenticated(self): + self.client.logout() + query_params = { + 'client_id': self.oidc_client.client_id, + } + response = self.client.get(self.url_prompt, query_params) + # Since user is unauthenticated on the backend, we send it back to client post logout + # registered uri. + self.assertEqual(response.status_code, 302) + self.assertEqual(response.headers['Location'], self.url_logout) + + def test_prompt_view_raising_404_since_user_unauthenticated_and_no_client(self): + self.client.logout() + response = self.client.get(self.url_prompt) + # Since user is unauthenticated and no client information is present, we just show + # not found page. + self.assertEqual(response.status_code, 404) + + def test_prompt_view_displaying_logout_decision_form_to_user(self): + query_params = { + 'client_id': self.oidc_client.client_id, + } + response = self.client.get(self.url_prompt, query_params) + # User is prompted to logout with client information displayed. + self.assertContains(response, '

Hi johndoe@example.com, are you sure you want to log out from Some Client app?

', status_code=200, html=True) + def test_prompt_view_displaying_logout_decision_form_to_user_no_client(self): + response = self.client.get(self.url_prompt) + # User is prompted to logout without client information displayed. + self.assertContains(response, '

Hi johndoe@example.com, are you sure you want to log out?

', status_code=200, html=True) + + @mock.patch(settings.get('OIDC_AFTER_END_SESSION_HOOK')) + def test_prompt_view_user_logged_out_after_form_allowed(self, end_session_hook): + self.assertIn('_auth_user_id', self.client.session) + # We want to POST to /end-session-prompt/?client_id=ABC endpoint. + url_prompt_with_client = self.url_prompt + '?' + urlencode({ + 'client_id': self.oidc_client.client_id, + }) + data = { + 'allow': 'Anything', # This means user allowed being logged out. + } + response = self.client.post(url_prompt_with_client, data) + # Ensure user is now logged out and redirected to client post logout uri. + self.assertNotIn('_auth_user_id', self.client.session) + self.assertEqual(response.status_code, 302) + self.assertEqual(response.headers['Location'], self.url_logout) + # End session hook should be called. + self.assertTrue(end_session_hook.called) + self.assertTrue(end_session_hook.call_count == 1) + + @mock.patch(settings.get('OIDC_AFTER_END_SESSION_HOOK')) + def test_prompt_view_user_logged_out_after_form_not_allowed(self, end_session_hook): + self.assertIn('_auth_user_id', self.client.session) + # We want to POST to /end-session-prompt/?client_id=ABC endpoint. + url_prompt_with_client = self.url_prompt + '?' + urlencode({ + 'client_id': self.oidc_client.client_id, + }) + response = self.client.post(url_prompt_with_client) # No data. + # Ensure user is still logged in and redirected to client post logout uri. + self.assertIn('_auth_user_id', self.client.session) + self.assertEqual(response.status_code, 302) + self.assertEqual(response.headers['Location'], self.url_logout) + # End session hook should not be called. + self.assertFalse(end_session_hook.called) + @mock.patch(settings.get('OIDC_AFTER_END_SESSION_HOOK')) - def test_call_post_end_session_hook(self, hook_function): - self.client.get(self.url) - self.assertTrue(hook_function.called, 'OIDC_AFTER_END_SESSION_HOOK should be called') - self.assertTrue( - hook_function.call_count == 1, - 'OIDC_AFTER_END_SESSION_HOOK should be called once') + def test_prompt_view_user_not_logged_out_after_form_not_allowed_no_client(self, end_session_hook): + self.assertIn('_auth_user_id', self.client.session) + response = self.client.post(self.url_prompt) # No data. + # Ensure user is still logged in and 404 NOT FOUND was raised. + self.assertIn('_auth_user_id', self.client.session) + self.assertEqual(response.status_code, 404) + # End session hook should not be called. + self.assertFalse(end_session_hook.called) diff --git a/oidc_provider/urls.py b/oidc_provider/urls.py index 08d219f0..1250a9ba 100644 --- a/oidc_provider/urls.py +++ b/oidc_provider/urls.py @@ -12,6 +12,7 @@ re_path(r'^token/?$', csrf_exempt(views.TokenView.as_view()), name='token'), re_path(r'^userinfo/?$', csrf_exempt(views.userinfo), name='userinfo'), re_path(r'^end-session/?$', views.EndSessionView.as_view(), name='end-session'), + re_path(r'^end-session-prompt/?$', views.EndSessionPromptView.as_view(), name='end-session-prompt'), re_path(r'^\.well-known/openid-configuration/?$', views.ProviderInfoView.as_view(), name='provider-info'), re_path(r'^introspect/?$', views.TokenIntrospectionView.as_view(), name='token-introspection'), diff --git a/oidc_provider/views.py b/oidc_provider/views.py index 4a2c94ce..748961f6 100644 --- a/oidc_provider/views.py +++ b/oidc_provider/views.py @@ -3,7 +3,6 @@ from django.views.decorators.csrf import csrf_exempt -from oidc_provider.lib.endpoints.introspection import TokenIntrospectionEndpoint try: from urllib import urlencode from urlparse import urlsplit, parse_qs, urlunsplit @@ -13,7 +12,6 @@ from Cryptodome.PublicKey import RSA from django.contrib.auth.views import ( redirect_to_login, - LogoutView, ) try: from django.urls import reverse @@ -22,18 +20,19 @@ from django.db import transaction from django.contrib.auth import logout as django_user_logout from django.core.cache import cache -from django.http import JsonResponse, HttpResponse +from django.http import JsonResponse, HttpResponse, Http404 from django.shortcuts import render from django.template.loader import render_to_string from django.utils.decorators import method_decorator from django.views.decorators.clickjacking import xframe_options_exempt from django.views.decorators.http import require_http_methods -from django.views.generic import View +from django.views.generic import TemplateView, View from jwkest import long_to_base64 from oidc_provider.compat import get_attr_or_callable from oidc_provider.lib.claims import StandardScopeClaims from oidc_provider.lib.endpoints.authorize import AuthorizeEndpoint +from oidc_provider.lib.endpoints.introspection import TokenIntrospectionEndpoint from oidc_provider.lib.endpoints.token import TokenEndpoint from oidc_provider.lib.errors import ( AuthorizeError, @@ -62,6 +61,7 @@ logger = logging.getLogger(__name__) OIDC_TEMPLATES = settings.get('OIDC_TEMPLATES') +after_end_session_hook = settings.get('OIDC_AFTER_END_SESSION_HOOK', import_str=True) class AuthorizeView(View): @@ -343,43 +343,107 @@ def get(self, request, *args, **kwargs): return response -class EndSessionView(LogoutView): - def dispatch(self, request, *args, **kwargs): - id_token_hint = request.GET.get('id_token_hint', '') - post_logout_redirect_uri = request.GET.get('post_logout_redirect_uri', '') - state = request.GET.get('state', '') - client = None - - next_page = settings.get('OIDC_LOGIN_URL') - after_end_session_hook = settings.get('OIDC_AFTER_END_SESSION_HOOK', import_str=True) - - if id_token_hint: - client_id = client_id_from_id_token(id_token_hint) - try: - client = Client.objects.get(client_id=client_id) - if post_logout_redirect_uri in client.post_logout_redirect_uris: - if state: - uri = urlsplit(post_logout_redirect_uri) - query_params = parse_qs(uri.query) - query_params['state'] = state - uri = uri._replace(query=urlencode(query_params, doseq=True)) - next_page = urlunsplit(uri) - else: - next_page = post_logout_redirect_uri - except Client.DoesNotExist: - pass +class EndSessionView(View): + http_method_names = ['get', 'post'] + @classmethod + def logout_user(cls, request, id_token_hint=None, post_logout_redirect_uri=None, state=None, client=None, next_page=None): after_end_session_hook( request=request, id_token=id_token_hint, post_logout_redirect_uri=post_logout_redirect_uri, state=state, client=client, - next_page=next_page + next_page=next_page, ) + django_user_logout(request) + + @method_decorator(csrf_exempt) + def dispatch(self, request, *args, **kwargs): + self.id_token_hint = request.POST.get('id_token_hint') or request.GET.get('id_token_hint') + self.post_logout_redirect_uri = request.POST.get('post_logout_redirect_uri') or request.GET.get('post_logout_redirect_uri') + self.state = request.POST.get('state') or request.GET.get('state') + self.client = None - self.next_page = next_page - return super(EndSessionView, self).dispatch(request, *args, **kwargs) + if self.id_token_hint: + client_id = client_id_from_id_token(self.id_token_hint) + try: + self.client = Client.objects.get(client_id=client_id) + + if self.post_logout_redirect_uri: + if not self.post_logout_redirect_uri in self.client.post_logout_redirect_uris: + return redirect(reverse('oidc_provider:end-session-prompt') + '?' + urlencode({ + 'client_id': client_id, + })) + elif self.client.post_logout_redirect_uris: + self.post_logout_redirect_uri = self.client.post_logout_redirect_uris[0] + else: + self.logout_user(request, self.id_token_hint, self.post_logout_redirect_uri, self.state, self.client) + raise Http404("You have successfully logged out!") + + if self.state: + uri = urlsplit(self.post_logout_redirect_uri) + query_params = parse_qs(uri.query) + query_params['state'] = self.state + uri = uri._replace(query=urlencode(query_params, doseq=True)) + next_page = urlunsplit(uri) + else: + next_page = self.post_logout_redirect_uri + + self.logout_user(request, self.id_token_hint, self.post_logout_redirect_uri, self.state, self.client, next_page) + return redirect(next_page) + except Client.DoesNotExist: + pass + + return redirect(reverse('oidc_provider:end-session-prompt')) + + +class EndSessionPromptView(TemplateView): + http_method_names = ['get', 'post'] + template_name = 'oidc_provider/end_session_prompt.html' + + def dispatch(self, request, *args, **kwargs): + self.client_id = request.GET.get('client_id') + self.client = Client.objects.filter(client_id=self.client_id).first() + return super(EndSessionPromptView, self).dispatch(request, *args, **kwargs) + + def get(self, request, *args, **kwargs): + # If user is not authenticated, we should redirect to client post logout uri if exists, + # otherwhise, just raise a not found error. + if not get_attr_or_callable(request.user, 'is_authenticated'): + if self.client and self.client.post_logout_redirect_uris: + return redirect(self.client.post_logout_redirect_uris[0]) + else: + raise Http404("You are already logged out!") + + return super(EndSessionPromptView, self).get(request, *args, **kwargs) + + def get_context_data(self, **kwargs): + context = super(EndSessionPromptView, self).get_context_data(**kwargs) + context['client'] = self.client + + end_session_prompt_url = reverse('oidc_provider:end-session-prompt') + if self.client_id: + end_session_prompt_url += '?' + urlencode({ + 'client_id': self.client_id, + }) + context['end_session_prompt_url'] = end_session_prompt_url + + return context + + def post(self, request, *args, **kwargs): + allowed = request.POST.get('allow') + next_page = self.client.post_logout_redirect_uris[0] if self.client and self.client.post_logout_redirect_uris else None + + # Only logout users if they allow it. + if allowed: + EndSessionView.logout_user(request, client=self.client, next_page=next_page) + + # Redirect to post logout uri if client is present. + if next_page: + return redirect(next_page) + raise Http404("You have successfully logged out!" if allowed else "You can close this window.") + class CheckSessionIframeView(View):