Make logout a POST and end the Keycloak session

Clicking Logout returned 405: the base templates linked to the logout view
with a GET, which mozilla-django-oidc refuses. Both portals now post a CSRF
form instead.

A POST alone was not enough. OIDC_OP_LOGOUT_URL_METHOD is now set, so logout
builds the realm's RP-initiated end-session URL with the stored id token and
a registered post-logout redirect. Without it Keycloak kept its session and
silently signed the user back in, so the sign-in screen could never be
reached again on that browser.

Registered the post-logout redirect URIs on the ixg-platform client and
trimmed its redirect URIs to the callback on the hosts that actually serve
the platform.
This commit is contained in:
ixgadmin 2026-09-21 12:53:14 +00:00
parent 19d67555da
commit 8c8e7908fd
5 changed files with 103 additions and 3 deletions

29
apps/users/oidc.py Normal file
View file

@ -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)}"

View file

@ -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")

View file

@ -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.

View file

@ -56,7 +56,10 @@ body { font-size: .875rem; background: #f5f6fa; }
<div class="d-flex align-items-center gap-3">
<a href="/admin/" class="text-decoration-none text-secondary"><i class="bi bi-shield-lock"></i> Admin</a>
<span class="text-secondary">{{ request.user.full_name|default:request.user.email }}</span>
<a href="{% url 'oidc_logout' %}" class="btn btn-sm btn-outline-secondary">Logout</a>
<form method="post" action="{% url 'oidc_logout' %}" class="m-0">
{% csrf_token %}
<button type="submit" class="btn btn-sm btn-outline-secondary">Logout</button>
</form>
</div>
</div>
<div class="content">

View file

@ -50,7 +50,10 @@ body { font-size: .875rem; background: #f0fdf4; }
<span></span>
<div class="d-flex align-items-center gap-3">
<span class="text-secondary">{{ request.user.full_name|default:request.user.email }}</span>
<a href="{% url 'oidc_logout' %}" class="btn btn-sm btn-outline-secondary">Logout</a>
<form method="post" action="{% url 'oidc_logout' %}" class="m-0">
{% csrf_token %}
<button type="submit" class="btn btn-sm btn-outline-secondary">Logout</button>
</form>
</div>
</div>
<div class="content">