diff --git a/apps/users/oidc.py b/apps/users/oidc.py new file mode 100644 index 0000000..5da8d57 --- /dev/null +++ b/apps/users/oidc.py @@ -0,0 +1,29 @@ +"""OpenID Connect helpers beyond the authentication backend. + +Signing out has two halves. Clearing the Django session is not enough: Keycloak +keeps its own session, so the next request to a protected page silently signs the +user back in and the sign-in screen is never shown. ``provider_logout`` is wired +to ``OIDC_OP_LOGOUT_URL_METHOD`` and builds the realm's RP-initiated logout URL, +which ends the Keycloak session too and returns the browser to the platform root. + +Endpoints come from settings (django-environ); nothing is hard-coded. +""" +from urllib.parse import urlencode + +from django.conf import settings + + +def provider_logout(request): + """Return Keycloak's end-session URL for the current session. + + mozilla-django-oidc calls this before flushing the Django session, so the + stored id token is still available. ``id_token_hint`` tells Keycloak which + session to end, and ``post_logout_redirect_uri`` is where it sends the + browser afterwards — that URI must be registered on the client + (``post.logout.redirect.uris``) or Keycloak refuses to redirect back. + """ + id_token = request.session.get("oidc_id_token", "") + params = {"post_logout_redirect_uri": request.build_absolute_uri("/")} + if id_token: + params["id_token_hint"] = id_token + return f"{settings.OIDC_OP_LOGOUT_ENDPOINT}?{urlencode(params)}" diff --git a/apps/users/tests.py b/apps/users/tests.py index fb4f530..28b5b70 100644 --- a/apps/users/tests.py +++ b/apps/users/tests.py @@ -7,11 +7,16 @@ correctly would still flip to staff the first time they signed in. The second case is the one that passes a first test and fails later, so it is covered directly. """ +from urllib.parse import quote + +from django.conf import settings from django.contrib.auth import get_user_model -from django.test import TestCase +from django.test import RequestFactory, TestCase +from django.urls import reverse from .auth import KeycloakOIDCBackend, is_staff_for_role from .keycloak import _split_name +from .oidc import provider_logout User = get_user_model() @@ -105,3 +110,60 @@ class StaffRoleTests(TestCase): user.refresh_from_db() self.assertTrue(user.is_superuser) self.assertTrue(user.is_staff) + + +class LogoutTests(TestCase): + """Signing out must end the Keycloak session, not just the Django one. + + The bug: the base templates linked to the logout view with a GET, which + mozilla-django-oidc refuses (405); and even a successful POST only cleared + the Django session, so Keycloak silently signed the user back in and the + sign-in screen could never be reached again. These pin all three parts: GET + is refused, POST clears the session, and the URL handed to the browser ends + the Keycloak session and carries a registered return trip. + """ + + def test_get_logout_is_refused(self): + response = self.client.get(reverse("oidc_logout")) + self.assertEqual(response.status_code, 405) + + def test_post_logout_clears_the_django_session(self): + user = User.objects.create_user( + email="logout-ops@example.com", role="ops", is_staff=True + ) + self.client.force_login(user) + response = self.client.post(reverse("oidc_logout")) + self.assertEqual(response.status_code, 302) + self.assertNotIn("_auth_user_id", self.client.session) + + def test_logout_url_targets_the_realm_end_session_endpoint(self): + request = RequestFactory().get("/portal/") + request.session = {"oidc_id_token": "id-token-abc"} + url = provider_logout(request) + self.assertTrue(url.startswith(settings.OIDC_OP_LOGOUT_ENDPOINT)) + self.assertIn("id_token_hint=id-token-abc", url) + self.assertIn("post_logout_redirect_uri=", url) + self.assertIn(quote("http://testserver/", safe=""), url) + + def test_logout_url_omits_the_hint_when_no_token_is_stored(self): + request = RequestFactory().get("/portal/") + request.session = {} + self.assertNotIn("id_token_hint", provider_logout(request)) + + def test_the_buyer_portal_renders_a_post_logout_form(self): + buyer = User.objects.create_user( + email="logout-buyer@example.com", role="buyer", is_staff=False + ) + self.client.force_login(buyer) + response = self.client.get("/portal/") + self.assertContains(response, 'action="/oidc/logout/"') + self.assertContains(response, "csrfmiddlewaretoken") + + def test_the_ops_portal_renders_a_post_logout_form(self): + ops = User.objects.create_user( + email="logout-ops2@example.com", role="ops", is_staff=True + ) + self.client.force_login(ops) + response = self.client.get("/ops/") + self.assertContains(response, 'action="/oidc/logout/"') + self.assertContains(response, "csrfmiddlewaretoken") diff --git a/dsp_platform/settings.py b/dsp_platform/settings.py index 9769b8a..fd6ce09 100644 --- a/dsp_platform/settings.py +++ b/dsp_platform/settings.py @@ -179,6 +179,9 @@ OIDC_STORE_ACCESS_TOKEN = True OIDC_STORE_ID_TOKEN = True LOGIN_URL = "oidc_authentication_init" LOGOUT_REDIRECT_URL = "/" +# RP-initiated logout: end the Keycloak session as well as the Django one, or +# the next request silently re-authenticates (apps/users/oidc.py). +OIDC_OP_LOGOUT_URL_METHOD = "apps.users.oidc.provider_logout" # The root view dispatches by role (apps/core/views.RoleRedirectView), so a # buyer who signs in is not aimed at the admin area, and the RBAC middleware # can redirect a wrong-role request here without looping. diff --git a/templates/ops/base_ops.html b/templates/ops/base_ops.html index 0c4a926..257d073 100644 --- a/templates/ops/base_ops.html +++ b/templates/ops/base_ops.html @@ -56,7 +56,10 @@ body { font-size: .875rem; background: #f5f6fa; }