diff --git a/docs/django.rst b/docs/django.rst index 5f55360..56bbb93 100644 --- a/docs/django.rst +++ b/docs/django.rst @@ -1,125 +1,125 @@ Django components ================= ``swh-auth`` implements some generic backends, models, views and middlewares to easily authenticate a user with Keycloak and OpenID Connect. OIDC User model --------------- When ``swh-auth`` authenticates users with OIDC in a Django application, it creates an instance of the :class:`swh.auth.django.models.OIDCUser` model and attaches it to the input ``django.http.HttpRequest`` object. That model acts as a proxy for the ``django.contrib.auth.models.User`` model and is not persisted to database as user information is already stored in Keycloak database. As a consequence it will not be considered when calling the ``makemigrations`` command from Django application management CLI. Authentication backends ----------------------- ``swh-auth`` provides two authentication backends to login users in Django applications: - :class:`swh.auth.django.backends.OIDCAuthorizationCodePKCEBackend`: authenticate users from a Web application UI - :class:`swh.auth.django.backends.OIDCBearerTokenAuthentication`: authenticate REST API users from bearer tokens sent in HTTP Authorization headers. These backends need to be configured through the following Django settings: - ``SWH_AUTH_SERVER_URL``: Base URL of the Keycloak server to interact with - ``SWH_AUTH_REALM_NAME``: Name of the realm to use in the Keycloak instance - ``SWH_AUTH_CLIENT_ID``: Name of the client to use in the realm .. warning:: These backends internally use the Django cache to store authenticated user data. In production environment, it is important to ensure the cache will be shared across the multiple WSGI workers (by using Django memcached cache backend for instance). Authorization Code flow with PKCE backend ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ This backend can be used to authenticate users with the OpenID Connect Authorization Code flow with PKCE (`Proof Key for Code Exchange`_). PKCE replaces the static secret used in the standard authorization code flow with a temporary one-time challenge, making it feasible to use in public clients. When using that backend, users are redirected to the Keycloak login UI and are asked to enter their credentials. Once successfully authenticated, users will be redirected back to the Django application. To use that backend, add ``"swh.auth.django.backends.OIDCAuthorizationCodePKCEBackend"`` to the ``AUTHENTICATION_BACKENDS`` Django setting. The backend must be used in collaboration with the dedicated :ref:`login-logout-views` implementing the authentication flow. Bearer token backend ^^^^^^^^^^^^^^^^^^^^ This backend for Django REST Framework enables to authenticate Web API users by sending long-lived OpenID Connect refresh tokens in HTTP Authorization headers. Long lived refresh tokens can be generated in Keycloak by opening an OpenID Connect session with the following scope: ``openid offline_access``. To use that backend, add ``"swh.auth.django.backends.OIDCBearerTokenAuthentication"`` to the ``REST_FRAMEWORK["DEFAULT_AUTHENTICATION_CLASSES"]`` Django setting. Users can then perform authenticated Web API calls by sending their refresh token in HTTP Authorization headers, for instance when using ``curl``:: curl -H "Authorization: Bearer ${TOKEN}" https://.... .. _login-logout-views: Login / logout views -------------------- In order to login / logout a user with OIDC Authorization code flow with PKCE, two dedicated Django views are available in ``swh-auth``: - ``oidc-login`` (``/oidc/login/`` URL path): initiate authentication flow -- ``oidc-logout`` (``/oidc/logout/`` URL path): terminate OIDC user session, a ``next_path`` +- ``oidc-logout`` (``/oidc/logout/`` URL path): terminate OIDC user session, a ``next`` query parameter can be used to redirect to a view of choice once a user is logged out Add ``swh.auth.django.views.urlpatterns`` to your Django application URLs to use them. Middlewares ----------- ``swh-auth`` provides the :class:`swh.auth.django.middlewares.OIDCSessionExpiredMiddleware` middleware. That middleware detects when a user previously logged in using the OpenID Connect authentication backend got his session expired. In that case it redirects to a Django view whose name is set in the ``SWH_AUTH_SESSION_EXPIRED_REDIRECT_VIEW`` Django setting (typically a logout view). The following query parameter will be set for that view: -- ``next_path``: requested URL before the detection of the OIDC session expiration +- ``next``: requested URL before the detection of the OIDC session expiration - ``remote_user``: indicates that the user was previously authenticated with OIDC Minimal application example --------------------------- A sample minimal Django application using all the features mentioned above can be found in `swh-auth Django tests tree`_. .. _Proof Key for Code Exchange: https://tools.ietf.org/html/rfc7636 .. _swh-auth Django tests tree: https://forge.softwareheritage.org/source/swh-auth/browse/master/swh/auth/tests/django/app/apptest/ diff --git a/swh/auth/django/backends.py b/swh/auth/django/backends.py index 5ac8985..b6a3c51 100644 --- a/swh/auth/django/backends.py +++ b/swh/auth/django/backends.py @@ -1,224 +1,224 @@ # Copyright (C) 2020-2022 The Software Heritage developers # See the AUTHORS file at the top-level directory of this distribution # License: GNU Affero General Public License version 3, or any later version # See top-level LICENSE file for more information from datetime import datetime import hashlib from typing import Any, Dict, Optional from django.core.cache import cache from django.http import HttpRequest from django.utils import timezone from rest_framework.authentication import BaseAuthentication from rest_framework.exceptions import AuthenticationFailed, ValidationError import sentry_sdk from swh.auth.django.models import OIDCUser from swh.auth.django.utils import ( keycloak_oidc_client, oidc_profile_cache_key, oidc_user_from_decoded_token, oidc_user_from_profile, ) from swh.auth.keycloak import ( ExpiredSignatureError, KeycloakError, KeycloakOpenIDConnect, keycloak_error_message, ) def _update_cached_oidc_profile( oidc_client: KeycloakOpenIDConnect, oidc_profile: Dict[str, Any], user: OIDCUser ) -> None: """ Update cached OIDC profile associated to a user if needed: when the profile is not stored in cache or when the authentication tokens have changed. Args: oidc_client: KeycloakOpenID wrapper oidc_profile: OIDC profile used to authenticate a user user: django model representing the authenticated user """ # put OIDC profile in cache or update it after token renewal cache_key = oidc_profile_cache_key(oidc_client, user.id) if ( cache.get(cache_key) is None or user.access_token != oidc_profile["access_token"] ): # set cache key TTL as refresh token expiration time assert user.refresh_expires_at ttl = int(user.refresh_expires_at.timestamp() - timezone.now().timestamp()) # save oidc_profile in cache cache.set(cache_key, user.oidc_profile, timeout=max(0, ttl)) class OIDCAuthorizationCodePKCEBackend: """ Django authentication backend using Keycloak OpenID Connect authorization code flow with PKCE ("Proof Key for Code Exchange"). To use that backend globally in your django application, proceed as follow: * add ``"swh.auth.django.backends.OIDCAuthorizationCodePKCEBackend"`` to the ``AUTHENTICATION_BACKENDS`` django setting * configure Keycloak URL, realm and client by adding ``SWH_AUTH_SERVER_URL``, ``SWH_AUTH_REALM_NAME`` and ``SWH_AUTH_CLIENT_ID`` in django settings * add ``swh.auth.django.views.urlpatterns`` to your django application URLs * add an HTML link targeting the ``"oidc-login"`` django view in your application views * once a user is logged in, add an HTML link targeting the ``"oidc-logout"`` - django view in your application views (a ``next_path`` query parameter + django view in your application views (a ``next`` query parameter can be used to redirect to a view of choice once the user is logged out) """ def authenticate( self, request: HttpRequest, code: str, code_verifier: str, redirect_uri: str ) -> Optional[OIDCUser]: user = None try: oidc_client = keycloak_oidc_client() # try to authenticate user with OIDC PKCE authorization code flow oidc_profile = oidc_client.authorization_code( code, redirect_uri, code_verifier=code_verifier ) # create Django user user = oidc_user_from_profile(oidc_client, oidc_profile) # update cached oidc profile if needed _update_cached_oidc_profile(oidc_client, oidc_profile, user) except Exception as e: sentry_sdk.capture_exception(e) return user def get_user(self, user_id: int) -> Optional[OIDCUser]: # get oidc profile from cache oidc_client = keycloak_oidc_client() cache_key = oidc_profile_cache_key(oidc_client, user_id) oidc_profile = cache.get(cache_key) if oidc_profile: try: user = oidc_user_from_profile(oidc_client, oidc_profile) # update cached oidc profile if needed _update_cached_oidc_profile(oidc_client, oidc_profile, user) # restore auth backend setattr(user, "backend", f"{__name__}.{self.__class__.__name__}") return user except KeycloakError as ke: error_msg = keycloak_error_message(ke) if error_msg == "invalid_grant: Session not active": # user session no longer active, remove oidc profile from cache cache.delete(cache_key) else: sentry_sdk.capture_exception(ke) return None except Exception as e: sentry_sdk.capture_exception(e) return None else: return None class OIDCBearerTokenAuthentication(BaseAuthentication): """ Django REST Framework authentication backend using bearer tokens for Keycloak OpenID Connect. It enables to authenticate a Web API user by sending a long-lived OpenID Connect refresh token in HTTP Authorization headers. Long lived refresh tokens can be generated by opening an OpenID Connect session with the following scope: ``openid offline_access``. To use that backend globally in your DRF application, proceed as follow: * add ``"swh.auth.django.backends.OIDCBearerTokenAuthentication"`` to the ``REST_FRAMEWORK["DEFAULT_AUTHENTICATION_CLASSES"]`` django setting. * configure Keycloak URL, realm and client by adding ``SWH_AUTH_SERVER_URL``, ``SWH_AUTH_REALM_NAME`` and ``SWH_AUTH_CLIENT_ID`` in django settings Users will then be able to perform authenticated Web API calls by sending their refresh token in HTTP Authorization headers, for instance: ``curl -H "Authorization: Bearer ${TOKEN}" https://...``. """ def authenticate(self, request): auth_header = request.META.get("HTTP_AUTHORIZATION") if auth_header is None: return None try: auth_type, refresh_token = auth_header.split(" ", 1) except ValueError: raise AuthenticationFailed("Invalid HTTP authorization header format") if auth_type != "Bearer": raise AuthenticationFailed( (f"Invalid or unsupported HTTP authorization" f" type ({auth_type}).") ) try: oidc_client = keycloak_oidc_client() # compute a cache key from the token that does not exceed # memcached key size limit hasher = hashlib.sha1() hasher.update(refresh_token.encode("ascii")) cache_key = f"api_token_{hasher.hexdigest()}" # check if an access token is cached access_token = cache.get(cache_key) if access_token is not None: # attempt to decode access token try: decoded_token = oidc_client.decode_token(access_token) # access token has expired except ExpiredSignatureError: decoded_token = None if access_token is None or decoded_token is None: # get a new access token from authentication provider access_token = oidc_client.refresh_token(refresh_token)["access_token"] # decode access token decoded_token = oidc_client.decode_token(access_token) # compute access token expiration exp = datetime.fromtimestamp(decoded_token["exp"]) ttl = int(exp.timestamp() - timezone.now().timestamp()) # save access token in cache while it is valid cache.set(cache_key, access_token, timeout=max(0, ttl)) # create Django user user = oidc_user_from_decoded_token(decoded_token, oidc_client.client_id) except UnicodeEncodeError: raise ValidationError("Invalid bearer token") except KeycloakError as ke: error_msg = keycloak_error_message(ke) if error_msg in ( "invalid_grant: Offline session not active", "invalid_grant: Offline user session not found", ): error_msg = ( "Bearer token expired after a long period of inactivity; " "please generate a new one." ) raise AuthenticationFailed(error_msg) except Exception as e: raise AuthenticationFailed(str(e)) return user, None diff --git a/swh/auth/django/middlewares.py b/swh/auth/django/middlewares.py index 77a9a0a..1763630 100644 --- a/swh/auth/django/middlewares.py +++ b/swh/auth/django/middlewares.py @@ -1,68 +1,68 @@ # Copyright (C) 2020-2021 The Software Heritage developers # See the AUTHORS file at the top-level directory of this distribution # License: GNU Affero General Public License version 3, or any later version # See top-level LICENSE file for more information from django.conf import settings from django.contrib.auth import BACKEND_SESSION_KEY from django.http.response import HttpResponseRedirect from swh.auth.django.utils import reverse class OIDCSessionExpiredMiddleware: """ Middleware for checking OpenID Connect user session expiration. That middleware detects when a user previously logged in using the OpenID Connect authentication backend got his session expired. In that case it will perform a redirection to a django view whose name must be set in the ``SWH_AUTH_SESSION_EXPIRED_REDIRECT_VIEW`` django setting (typically a logout view). The following query parameter will be set for that view: - * ``next_path``: requested URL before the detection of the session expiration + * ``next``: requested URL before the detection of the session expiration * ``remote_user``: indicates that the user was previously authenticated with OIDC """ def __init__(self, get_response=None): self.get_response = get_response self.redirect_view = getattr( settings, "SWH_AUTH_SESSION_EXPIRED_REDIRECT_VIEW", None ) if self.redirect_view is None: raise ValueError( "SWH_AUTH_SESSION_EXPIRED_REDIRECT_VIEW django setting " "is mandatory to instantiate OIDCSessionExpiredMiddleware class" ) self.exempted_urls = [ reverse(v) for v in ( self.redirect_view, "oidc-login", "oidc-login-complete", "oidc-logout", ) ] def __call__(self, request): if ( request.method != "GET" or request.user.is_authenticated or BACKEND_SESSION_KEY not in request.session or "OIDC" not in request.session[BACKEND_SESSION_KEY] or request.path in self.exempted_urls ): return self.get_response(request) # At that point, we know that a OIDC user was previously logged in # and his session has expired. # Redirect to a view specified in django settings. - next_path = request.get_full_path() + next = request.get_full_path() logout_url = reverse( - self.redirect_view, query_params={"next_path": next_path, "remote_user": 1} + self.redirect_view, query_params={"next": next, "remote_user": 1} ) return HttpResponseRedirect(logout_url) diff --git a/swh/auth/django/views.py b/swh/auth/django/views.py index b463bec..e7e12e3 100644 --- a/swh/auth/django/views.py +++ b/swh/auth/django/views.py @@ -1,152 +1,152 @@ # Copyright (C) 2020-2022 The Software Heritage developers # See the AUTHORS file at the top-level directory of this distribution # License: GNU Affero General Public License version 3, or any later version # See top-level LICENSE file for more information from typing import Any, Dict, cast import uuid from django.contrib.auth import authenticate, login, logout from django.core.cache import cache from django.http import HttpRequest from django.http.response import ( HttpResponse, HttpResponseBadRequest, HttpResponseRedirect, HttpResponseServerError, ) from django.urls import re_path as url from swh.auth.django.models import OIDCUser from swh.auth.django.utils import keycloak_oidc_client, oidc_profile_cache_key, reverse from swh.auth.keycloak import KeycloakError, keycloak_error_message from swh.auth.utils import gen_oidc_pkce_codes def oidc_login_view(request: HttpRequest, redirect_uri: str, scope: str = "openid"): """ Helper view function that initiates a login process using OIDC authorization code flow with PKCE. OIDC session scope can be modified using the dedicated parameter. """ # generate a CSRF token state = str(uuid.uuid4()) code_verifier, code_challenge = gen_oidc_pkce_codes() request.session["login_data"] = { "code_verifier": code_verifier, "state": state, "redirect_uri": redirect_uri, - "next_path": request.GET.get("next_path", ""), + "next": request.GET.get("next", ""), } authorization_url_params = { "state": state, "code_challenge": code_challenge, "code_challenge_method": "S256", "scope": scope, } try: oidc_client = keycloak_oidc_client() authorization_url = oidc_client.authorization_url( redirect_uri, **authorization_url_params ) except KeycloakError as ke: return HttpResponseServerError(keycloak_error_message(ke)) return HttpResponseRedirect(authorization_url) def get_oidc_login_data(request: HttpRequest) -> Dict[str, Any]: """ Check and get login data stored in django session. """ if "login_data" not in request.session: raise Exception("Login process has not been initialized.") login_data = request.session["login_data"] if "code" not in request.GET or "state" not in request.GET: raise ValueError("Missing query parameters for authentication.") # get CSRF token returned by OIDC server state = request.GET["state"] if state != login_data["state"]: raise ValueError("Wrong CSRF token, aborting login process.") return login_data def oidc_login(request: HttpRequest) -> HttpResponse: """ Django view to initiate login process using OpenID Connect authorization code flow with PKCE. """ redirect_uri = reverse("oidc-login-complete", request=request) return oidc_login_view(request, redirect_uri=redirect_uri) def oidc_login_complete(request: HttpRequest) -> HttpResponse: """ Django view to finalize login process using OpenID Connect authorization code flow with PKCE. """ if "error" in request.GET: return HttpResponseServerError(request.GET["error"]) try: login_data = get_oidc_login_data(request) except ValueError as ve: return HttpResponseBadRequest(str(ve)) except Exception as e: return HttpResponseServerError(str(e)) - next_path = login_data["next_path"] or request.build_absolute_uri("/") + next = login_data["next"] or request.build_absolute_uri("/") user = authenticate( request=request, code=request.GET["code"], code_verifier=login_data["code_verifier"], redirect_uri=login_data["redirect_uri"], ) if user is None: return HttpResponseServerError("User authentication failed.") login(request, user) - return HttpResponseRedirect(next_path) + return HttpResponseRedirect(next) def oidc_logout(request: HttpRequest) -> HttpResponse: """ Django view to logout using OpenID Connect. """ user = request.user logout(request) if hasattr(user, "refresh_token"): user = cast(OIDCUser, user) refresh_token = cast(str, user.refresh_token) try: # end OpenID Connect session oidc_client = keycloak_oidc_client() oidc_client.logout(refresh_token) except KeycloakError as ke: return HttpResponseServerError(keycloak_error_message(ke)) # remove user data from cache cache.delete(oidc_profile_cache_key(oidc_client, user.id)) - return HttpResponseRedirect(request.GET.get("next_path", "/")) + return HttpResponseRedirect(request.GET.get("next", "/")) urlpatterns = [ url(r"^oidc/login/$", oidc_login, name="oidc-login"), url(r"^oidc/login-complete/$", oidc_login_complete, name="oidc-login-complete"), url(r"^oidc/logout/$", oidc_logout, name="oidc-logout"), ] diff --git a/swh/auth/tests/django/test_middlewares.py b/swh/auth/tests/django/test_middlewares.py index 51ccbfb..12cfc47 100644 --- a/swh/auth/tests/django/test_middlewares.py +++ b/swh/auth/tests/django/test_middlewares.py @@ -1,71 +1,69 @@ # Copyright (C) 2020-2021 The Software Heritage developers # See the AUTHORS file at the top-level directory of this distribution # License: GNU Affero General Public License version 3, or any later version # See top-level LICENSE file for more information from django.core.cache import cache from django.test import modify_settings, override_settings import pytest from swh.auth.django.utils import oidc_profile_cache_key, reverse @pytest.mark.django_db @override_settings(SWH_AUTH_SESSION_EXPIRED_REDIRECT_VIEW=None) def test_oidc_session_expired_middleware_missing_setting(client, keycloak_oidc): client.login(code="", code_verifier="", redirect_uri="") keycloak_oidc.authorization_code.assert_called() url = reverse("root") with pytest.raises(ValueError, match="setting is mandatory"): client.get(url) @pytest.mark.django_db @modify_settings( MIDDLEWARE={"remove": ["swh.auth.django.middlewares.OIDCSessionExpiredMiddleware"]} ) def test_oidc_session_expired_middleware_disabled(client, keycloak_oidc): # authenticate user client.login(code="", code_verifier="", redirect_uri="") keycloak_oidc.authorization_code.assert_called() url = reverse("root") # visit url first to get user from response response = client.get(url) assert response.status_code == 200 # simulate OIDC session expiration cache.delete(oidc_profile_cache_key(keycloak_oidc, response.wsgi_request.user.id)) # no redirection when session has expired response = client.get(url) assert response.status_code == 200 @pytest.mark.django_db def test_oidc_session_expired_middleware_enabled(client, keycloak_oidc): # authenticate user client.login(code="", code_verifier="", redirect_uri="") keycloak_oidc.authorization_code.assert_called() url = reverse("root") # visit url first to get user from response response = client.get(url) assert response.status_code == 200 # simulate OIDC session expiration cache.delete(oidc_profile_cache_key(keycloak_oidc, response.wsgi_request.user.id)) # should redirect to logout page response = client.get(url) assert response.status_code == 302 - silent_refresh_url = reverse( - "logout", query_params={"next_path": url, "remote_user": 1} - ) + silent_refresh_url = reverse("logout", query_params={"next": url, "remote_user": 1}) assert response["location"] == silent_refresh_url diff --git a/swh/auth/tests/django/test_views.py b/swh/auth/tests/django/test_views.py index ecc34d8..e6bfe87 100644 --- a/swh/auth/tests/django/test_views.py +++ b/swh/auth/tests/django/test_views.py @@ -1,281 +1,281 @@ # Copyright (C) 2020-2021 The Software Heritage developers # See the AUTHORS file at the top-level directory of this distribution # License: GNU Affero General Public License version 3, or any later version # See top-level LICENSE file for more information import json from urllib.parse import urljoin, urlparse import uuid from django.contrib.auth.models import AnonymousUser, User from django.http import QueryDict import pytest from swh.auth.django.models import OIDCUser from swh.auth.django.utils import reverse from swh.auth.keycloak import KeycloakError from swh.auth.tests.django.django_asserts import assert_contains from swh.auth.tests.sample_data import CLIENT_ID def _check_oidc_login_code_flow_data( request, response, keycloak_oidc, redirect_uri, scope="openid" ): parsed_url = urlparse(response["location"]) authorization_url = keycloak_oidc.well_known()["authorization_endpoint"] query_dict = QueryDict(parsed_url.query) # check redirect url is valid assert urljoin(response["location"], parsed_url.path) == authorization_url assert "client_id" in query_dict assert query_dict["client_id"] == CLIENT_ID assert "response_type" in query_dict assert query_dict["response_type"] == "code" assert "redirect_uri" in query_dict assert query_dict["redirect_uri"] == redirect_uri assert "code_challenge_method" in query_dict assert query_dict["code_challenge_method"] == "S256" assert "scope" in query_dict assert query_dict["scope"] == scope assert "state" in query_dict assert "code_challenge" in query_dict # check a login_data has been registered in user session assert "login_data" in request.session login_data = request.session["login_data"] assert "code_verifier" in login_data assert "state" in login_data assert "redirect_uri" in login_data assert login_data["redirect_uri"] == query_dict["redirect_uri"] return login_data @pytest.mark.django_db def test_oidc_login_views_success(client, keycloak_oidc): """ Simulate a successful login authentication with OpenID Connect authorization code flow with PKCE. """ # user initiates login process login_url = reverse("oidc-login") # should redirect to Keycloak authentication page in order # for a user to login with its username / password response = client.get(login_url) assert response.status_code == 302 request = response.wsgi_request assert isinstance(request.user, AnonymousUser) login_data = _check_oidc_login_code_flow_data( request, response, keycloak_oidc, redirect_uri=reverse("oidc-login-complete", request=request), ) # once a user has identified himself in Keycloak, he is # redirected to the 'oidc-login-complete' view to # login in Django. # generate authorization code / session state in the same # manner as Keycloak code = f"{str(uuid.uuid4())}.{str(uuid.uuid4())}.{str(uuid.uuid4())}" session_state = str(uuid.uuid4()) login_complete_url = reverse( "oidc-login-complete", query_params={ "code": code, "state": login_data["state"], "session_state": session_state, }, ) # login process finalization, should redirect to root url by default response = client.get(login_complete_url) assert response.status_code == 302 request = response.wsgi_request assert response["location"] == request.build_absolute_uri("/") # user should be authenticated assert isinstance(request.user, OIDCUser) # check remote user has not been saved to Django database with pytest.raises(User.DoesNotExist): User.objects.get(username=request.user.username) @pytest.mark.django_db def test_oidc_logout_view_success(client, keycloak_oidc): """ Simulate a successful logout operation with OpenID Connect. """ # login our test user client.login(code="", code_verifier="", redirect_uri="") keycloak_oidc.authorization_code.assert_called() # user initiates logout - next_path = reverse("root") - oidc_logout_url = reverse("oidc-logout", query_params={"next_path": next_path}) + next = reverse("root") + oidc_logout_url = reverse("oidc-logout", query_params={"next": next}) # should redirect to logout page response = client.get(oidc_logout_url) assert response.status_code == 302 request = response.wsgi_request - assert response["location"] == next_path + assert response["location"] == next # should have been logged out in Keycloak oidc_profile = keycloak_oidc.login() keycloak_oidc.logout.assert_called_with(oidc_profile["refresh_token"]) # check effective logout in Django assert isinstance(request.user, AnonymousUser) @pytest.mark.django_db def test_oidc_login_view_failure(client, keycloak_oidc): """ Simulate a failed authentication with OpenID Connect. """ keycloak_oidc.set_auth_success(False) # user initiates login process login_url = reverse("oidc-login") # should render an error page response = client.get(login_url) assert response.status_code == 500 request = response.wsgi_request # no users should be logged in assert isinstance(request.user, AnonymousUser) # Simulate possible errors with OpenID Connect in the login complete view. def test_oidc_login_complete_view_no_login_data(client): # user initiates login process login_url = reverse("oidc-login-complete") # should return with error response = client.get(login_url) assert response.status_code == 500 assert_contains( response, "Login process has not been initialized.", status_code=500 ) def test_oidc_login_complete_view_missing_parameters(client): # simulate login process has been initialized session = client.session session["login_data"] = { "code_verifier": "", "state": str(uuid.uuid4()), "redirect_uri": "", - "next_path": "", + "next": "", } session.save() # user initiates login process login_url = reverse("oidc-login-complete") # should return with error response = client.get(login_url) assert response.status_code == 400 request = response.wsgi_request assert_contains( response, "Missing query parameters for authentication.", status_code=400 ) # no user should be logged in assert isinstance(request.user, AnonymousUser) def test_oidc_login_complete_wrong_csrf_token(client, keycloak_oidc): # simulate login process has been initialized session = client.session session["login_data"] = { "code_verifier": "", "state": str(uuid.uuid4()), "redirect_uri": "", - "next_path": "", + "next": "", } session.save() # user initiates login process login_url = reverse( "oidc-login-complete", query_params={"code": "some-code", "state": "some-state"} ) # should render an error page response = client.get(login_url) assert response.status_code == 400 request = response.wsgi_request assert_contains( response, "Wrong CSRF token, aborting login process.", status_code=400 ) # no user should be logged in assert isinstance(request.user, AnonymousUser) @pytest.mark.django_db def test_oidc_login_complete_wrong_code_verifier(client, keycloak_oidc): keycloak_oidc.set_auth_success(False) # simulate login process has been initialized session = client.session session["login_data"] = { "code_verifier": "", "state": str(uuid.uuid4()), "redirect_uri": "", - "next_path": "", + "next": "", } session.save() # check authentication error is reported login_url = reverse( "oidc-login-complete", query_params={"code": "some-code", "state": session["login_data"]["state"]}, ) # should render an error page response = client.get(login_url) assert response.status_code == 500 request = response.wsgi_request assert_contains(response, "User authentication failed.", status_code=500) # no user should be logged in assert isinstance(request.user, AnonymousUser) @pytest.mark.django_db def test_oidc_logout_view_failure(client, keycloak_oidc): """ Simulate a failed logout operation with OpenID Connect. """ # login our test user client.login(code="", code_verifier="", redirect_uri="") error = "unknown_error" error_message = json.dumps({"error": error}).encode() keycloak_oidc.logout.side_effect = KeycloakError( error_message=error_message, response_code=401 ) # user initiates logout process logout_url = reverse("oidc-logout") # should return with error response = client.get(logout_url) assert response.status_code == 500 request = response.wsgi_request assert_contains(response, error, status_code=500) # user should be logged out from Django anyway assert isinstance(request.user, AnonymousUser)