diff --git a/src/backend/core/authentication/views.py b/src/backend/core/authentication/views.py index 61fe0acf..709f2097 100644 --- a/src/backend/core/authentication/views.py +++ b/src/backend/core/authentication/views.py @@ -1,5 +1,6 @@ """Authentication Views for the People core app.""" +import copy from urllib.parse import urlencode from django.contrib import auth @@ -11,6 +12,12 @@ from django.utils import crypto from mozilla_django_oidc.utils import ( absolutify, ) +from mozilla_django_oidc.views import ( + OIDCAuthenticationCallbackView as MozillaOIDCAuthenticationCallbackView, +) +from mozilla_django_oidc.views import ( + OIDCAuthenticationRequestView as MozillaOIDCAuthenticationRequestView, +) from mozilla_django_oidc.views import ( OIDCLogoutView as MozillaOIDCOIDCLogoutView, ) @@ -135,3 +142,40 @@ class OIDCLogoutCallbackView(MozillaOIDCOIDCLogoutView): auth.logout(request) return HttpResponseRedirect(self.redirect_url) + + +class OIDCAuthenticationCallbackView(MozillaOIDCAuthenticationCallbackView): + """Custom callback view for handling the silent loging flow.""" + + @property + def failure_url(self): + """Override the failure URL property to handle silent login flow + + A silent login failure (e.g., no active user session) should not be + considered as an authentication failure. + """ + if self.request.session.get("silent", None): + del self.request.session["silent"] + self.request.session.save() + return self.success_url + return super().failure_url + + +class OIDCAuthenticationRequestView(MozillaOIDCAuthenticationRequestView): + """Custom authentication view for handling the silent loging flow.""" + + def get_extra_params(self, request): + """Handle 'prompt' extra parameter for the silent login flow + + This extra parameter is necessary to distinguish between a standard + authentication flow and the silent login flow. + """ + extra_params = self.get_settings("OIDC_AUTH_REQUEST_EXTRA_PARAMS", None) + if extra_params is None: + extra_params = {} + if request.GET.get("silent") == "true": + extra_params = copy.deepcopy(extra_params) + extra_params.update({"prompt": "none"}) + request.session["silent"] = True + request.session.save() + return extra_params diff --git a/src/backend/core/tests/authentication/test_views.py b/src/backend/core/tests/authentication/test_views.py index b06cc8cc..20a124df 100644 --- a/src/backend/core/tests/authentication/test_views.py +++ b/src/backend/core/tests/authentication/test_views.py @@ -15,7 +15,13 @@ import pytest from rest_framework.test import APIClient from core import factories -from core.authentication.views import OIDCLogoutCallbackView, OIDCLogoutView +from core.authentication.views import ( + MozillaOIDCAuthenticationCallbackView, + OIDCAuthenticationCallbackView, + OIDCAuthenticationRequestView, + OIDCLogoutCallbackView, + OIDCLogoutView, +) pytestmark = pytest.mark.django_db @@ -229,3 +235,125 @@ def test_view_logout_callback(): assert response.status_code == 302 assert response.url == "/example-logout" + + +@pytest.mark.parametrize("mocked_extra_params_setting", [{"foo": 123}, {}, None]) +def test_view_authentication_default(settings, mocked_extra_params_setting): + """By default, authentication request should not trigger silent login.""" + + settings.OIDC_AUTH_REQUEST_EXTRA_PARAMS = mocked_extra_params_setting + + user = factories.UserFactory() + + request = RequestFactory().request() + request.user = user + request.GET = {} + + view = OIDCAuthenticationRequestView() + extra_params = view.get_extra_params(request) + + assert extra_params == (mocked_extra_params_setting or {}) + + +@pytest.mark.parametrize("mocked_extra_params_setting", [{"foo": 123}, {}, None]) +def test_view_authentication_silent_false(settings, mocked_extra_params_setting): + """Ensure setting 'silent' parameter to a random value doesn't trigger the silent login flow.""" + + settings.OIDC_AUTH_REQUEST_EXTRA_PARAMS = mocked_extra_params_setting + + user = factories.UserFactory() + + request = RequestFactory().request() + request.user = user + request.GET = {"silent": "foo"} + + middleware = SessionMiddleware(get_response=lambda x: x) + middleware.process_request(request) + + view = OIDCAuthenticationRequestView() + extra_params = view.get_extra_params(request) + + assert extra_params == (mocked_extra_params_setting or {}) + assert not request.session.get("silent") + + +@pytest.mark.parametrize("mocked_extra_params_setting", [{"foo": 123}, {}, None]) +def test_view_authentication_silent_true(settings, mocked_extra_params_setting): + """If 'silent' parameter is set to True, the silent login should be triggered.""" + settings.OIDC_AUTH_REQUEST_EXTRA_PARAMS = mocked_extra_params_setting + + user = factories.UserFactory() + + request = RequestFactory().request() + request.user = user + request.GET = {"silent": "true"} + + middleware = SessionMiddleware(get_response=lambda x: x) + middleware.process_request(request) + + view = OIDCAuthenticationRequestView() + extra_params = view.get_extra_params(request) + expected_params = {"prompt": "none"} + + assert ( + extra_params == {**mocked_extra_params_setting, **expected_params} + if mocked_extra_params_setting + else expected_params + ) + assert request.session.get("silent") is True + + +@mock.patch.object( + MozillaOIDCAuthenticationCallbackView, + "failure_url", + new_callable=mock.PropertyMock, + return_value="foo", +) +def test_view_callback_failure_url(mocked_failure_url): + """Test default behavior of the 'failure_url' property""" + + user = factories.UserFactory() + + request = RequestFactory().request() + request.user = user + + middleware = SessionMiddleware(get_response=lambda x: x) + middleware.process_request(request) + + view = OIDCAuthenticationCallbackView() + view.request = request + + returned_url = view.failure_url + + mocked_failure_url.assert_called_once() + assert returned_url == "foo" + + +@mock.patch.object( + OIDCAuthenticationCallbackView, + "success_url", + new_callable=mock.PropertyMock, + return_value="foo", +) +def test_view_callback_failure_url_silent_login(mocked_success_url): + """If a silent login was initiated and failed, it should not be treated as a failure.""" + + user = factories.UserFactory() + + request = RequestFactory().request() + request.user = user + + middleware = SessionMiddleware(get_response=lambda x: x) + middleware.process_request(request) + + request.session["silent"] = True + request.session.save() + + view = OIDCAuthenticationCallbackView() + view.request = request + + returned_url = view.failure_url + + mocked_success_url.assert_called_once() + assert returned_url == "foo" + assert not request.session.get("silent") diff --git a/src/backend/meet/settings.py b/src/backend/meet/settings.py index 7a140bc1..65b6a587 100755 --- a/src/backend/meet/settings.py +++ b/src/backend/meet/settings.py @@ -284,6 +284,8 @@ class Base(Configuration): SESSION_COOKIE_AGE = 60 * 60 * 12 # OIDC - Authorization Code Flow + OIDC_AUTHENTICATE_CLASS = "core.authentication.views.OIDCAuthenticationRequestView" + OIDC_CALLBACK_CLASS = "core.authentication.views.OIDCAuthenticationCallbackView" OIDC_CREATE_USER = values.BooleanValue( default=True, environ_name="OIDC_CREATE_USER",