diff --git a/manage_breast_screening/auth/tests/test_views.py b/manage_breast_screening/auth/tests/test_views.py index ccfdc8233..81e5120f8 100644 --- a/manage_breast_screening/auth/tests/test_views.py +++ b/manage_breast_screening/auth/tests/test_views.py @@ -1,10 +1,12 @@ +import time from unittest.mock import ANY, Mock import pytest +from authlib.jose import JsonWebKey, jwt from django.conf import settings from django.contrib.auth import get_user_model from django.http import HttpResponse -from django.test import override_settings +from django.test import Client, override_settings from django.urls import reverse from pytest_django.asserts import assertInHTML @@ -304,3 +306,105 @@ def test_returns_500_on_error(self, client, monkeypatch): assert response.status_code == 500 assert response.json() == {"keys": []} + + +@pytest.mark.django_db +class TestCis2BackChannelLogout: + @pytest.fixture + def cis2_jwk(self): + return JsonWebKey.generate_key( + "RSA", 2048, is_private=True, options={"kid": "test-key-1"} + ) + + @pytest.fixture + def mock_cis2_client(self, monkeypatch, cis2_jwk): + mock_client = Mock() + mock_client.load_server_metadata.return_value = {"issuer": "test-issuer"} + mock_client.fetch_jwk_set.return_value = { + "keys": [cis2_jwk.as_dict(is_private=False)] + } + monkeypatch.setattr( + "manage_breast_screening.auth.views.get_cis2_client", + lambda: mock_client, + ) + return mock_client + + def _make_logout_token(self, jwk, sub, *, overrides=None): + now = int(time.time()) + payload = { + "iss": "test-issuer", + "aud": settings.CIS2_CLIENT_ID, + "iat": now, + "exp": now + 300, + "events": {"https://schemas.openid.net/event/backchannel-logout": {}}, + "sub": sub, + "sid": "not-used", + "jti": "not-used", + } + if overrides: + payload.update(overrides) + token = jwt.encode( + {"alg": "RS256", "kid": jwk.kid}, + payload, + jwk.as_dict(is_private=True), + ) + return token.decode("utf-8") + + def test_logs_out_user_for_valid_token(self, mock_cis2_client, cis2_jwk): + User = get_user_model() + user = User.objects.create_user(nhs_uid="user-123", email="user@example.com") + # Sign in on one client (representing the user's browser session) + user_client = Client() + user_client.force_login(user) + assert user.session_set.count() == 1 + + token = self._make_logout_token(cis2_jwk, sub=user.nhs_uid) + + response = Client().post( + reverse("auth:cis2_back_channel_logout"), + data={"logout_token": token}, + ) + + assert response.status_code == 200 + assert user.session_set.count() == 0 + + def test_rejects_request_with_missing_logout_token(self): + response = Client().post(reverse("auth:cis2_back_channel_logout"), data={}) + + assert response.status_code == 400 + assert b"Missing logout_token" in response.content + + def test_rejects_expired_token(self, mock_cis2_client, cis2_jwk): + User = get_user_model() + user = User.objects.create_user(nhs_uid="user-123", email="user@example.com") + user_client = Client() + user_client.force_login(user) + + now = int(time.time()) + token = self._make_logout_token( + cis2_jwk, + sub=user.nhs_uid, + overrides={"iat": now - 300, "exp": now - 120}, + ) + + response = Client().post( + reverse("auth:cis2_back_channel_logout"), + data={"logout_token": token}, + ) + + assert response.status_code == 400 + assert b"Invalid logout token" in response.content + assert user.session_set.count() == 1 + + def test_returns_ok_when_user_does_not_exist_locally( + self, mock_cis2_client, cis2_jwk + ): + token = self._make_logout_token(cis2_jwk, sub="unknown-user") + + response = Client().post( + reverse("auth:cis2_back_channel_logout"), + data={"logout_token": token}, + ) + + assert response.status_code == 200 + assert response.json() == {"status": "ok"} diff --git a/manage_breast_screening/auth/views.py b/manage_breast_screening/auth/views.py index 329036374..a4fc08183 100644 --- a/manage_breast_screening/auth/views.py +++ b/manage_breast_screening/auth/views.py @@ -1,6 +1,7 @@ import logging from authlib.integrations.base_client.errors import MismatchingStateError, OAuthError +from authlib.jose import JsonWebKey from django.conf import settings from django.contrib import messages from django.contrib.auth import authenticate, get_user_model @@ -169,7 +170,7 @@ def cis2_back_channel_logout(request): # Get the CIS2 client and prepare key loader for token verification client = get_cis2_client() metadata = client.load_server_metadata() - key_loader = client.create_load_key() + key_loader = _create_cis2_key_loader(client) try: claims = decode_logout_token(metadata["issuer"], key_loader, logout_token) except InvalidLogoutToken: @@ -193,6 +194,28 @@ def cis2_back_channel_logout(request): return JsonResponse({"status": "ok"}) +def _create_cis2_key_loader(client): + """Build a key loader for verifying CIS2-signed tokens. + + Force-refreshes the cached JWKS on a kid miss so newly rotated CIS2 signing keys + are picked up without a process restart. + """ + + def load_key(header, _payload): + jwk_set = JsonWebKey.import_key_set(client.fetch_jwk_set()) + try: + return jwk_set.find_by_kid( + header.get("kid"), use="sig", alg=header.get("alg") + ) + except ValueError: + jwk_set = JsonWebKey.import_key_set(client.fetch_jwk_set(force=True)) + return jwk_set.find_by_kid( + header.get("kid"), use="sig", alg=header.get("alg") + ) + + return load_key + + def _validate_id_assurance_level(level: int | str | None) -> str | None: if level is not None: level = int(level) diff --git a/pyproject.toml b/pyproject.toml index 44f6d3e95..8d81d141f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,7 +24,7 @@ dependencies = [ "azure-storage-queue (>=12.13.0,<13.0.0)", "pyyaml (>=6.0.2,<7.0.0)", "rules (>=3.5,<4.0)", - "authlib>=1.6.11,<2.0.0", + "authlib>=1.7.0,<2.0.0", "django-qsessions (>=2.0.0,<3.0.0)", "business-python (>=2.1.0,<3.0.0)", "django-extensions (>=4.1,<5.0)", diff --git a/uv.lock b/uv.lock index 4a4bab716..41d6540d0 100644 --- a/uv.lock +++ b/uv.lock @@ -45,14 +45,15 @@ wheels = [ [[package]] name = "authlib" -version = "1.6.11" +version = "1.7.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cryptography" }, + { name = "joserfc" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/28/10/b325d58ffe86815b399334a101e63bc6fa4e1953921cb23703b48a0a0220/authlib-1.6.11.tar.gz", hash = "sha256:64db35b9b01aeccb4715a6c9a6613a06f2bd7be2ab9d2eb89edd1dfc7580a38f", size = 165359, upload-time = "2026-04-16T07:22:50.279Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d9/82/4d0603f30c1b4629b1f091bb266b0d7986434891d6940a8c87f8098db24e/authlib-1.7.0.tar.gz", hash = "sha256:b3e326c9aa9cc3ea95fe7d89fd880722d3608da4d00e8a27e061e64b48d801d5", size = 175890, upload-time = "2026-04-18T11:00:28.559Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/57/2f/55fca558f925a51db046e5b929deb317ddb05afed74b22d89f4eca578980/authlib-1.6.11-py2.py3-none-any.whl", hash = "sha256:c8687a9a26451c51a34a06fa17bb97cb15bba46a6a626755e2d7f50da8bff3e3", size = 244469, upload-time = "2026-04-16T07:22:48.413Z" }, + { url = "https://files.pythonhosted.org/packages/ca/48/c954218b2a250e23f178f10167c4173fecb5a75d2c206f0a67ba58006c26/authlib-1.7.0-py2.py3-none-any.whl", hash = "sha256:e36817afb02f6f0b6bf55f150782499ddd6ddf44b402bb055d3263cc65ac9ae0", size = 258779, upload-time = "2026-04-18T11:00:26.64Z" }, ] [[package]] @@ -781,6 +782,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, ] +[[package]] +name = "joserfc" +version = "1.6.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/de/c6/de8fdbdfa75c8ca04fead38a82d573df8a82906e984c349d58665f459558/joserfc-1.6.4.tar.gz", hash = "sha256:34ce5f499bfcc5e9ad4cc75077f9278ab3227b71da9aaf28f9ab705f8a560d3c", size = 231866, upload-time = "2026-04-13T13:15:40.632Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b6/f7/210b27752e972edb36d239315b08d3eb6b14824cc4a590da2337d195260b/joserfc-1.6.4-py3-none-any.whl", hash = "sha256:3e4a22b509b41908989237a045e25c8308d5fd47ab96bdae2dd8057c6451003a", size = 70464, upload-time = "2026-04-13T13:15:39.259Z" }, +] + [[package]] name = "jsbeautifier" version = "1.15.4" @@ -893,7 +906,7 @@ dev = [ [package.metadata] requires-dist = [ - { name = "authlib", specifier = ">=1.6.11,<2.0.0" }, + { name = "authlib", specifier = ">=1.7.0,<2.0.0" }, { name = "azure-identity", specifier = ">=1.23.0,<2.0.0" }, { name = "azure-monitor-opentelemetry", specifier = ">=1.8.1,<2.0.0" }, { name = "azure-storage-blob", specifier = ">=12.25.1,<13.0.0" },