From 1f7198b6105429dc5cc32dac0d5c3c7c56554623 Mon Sep 17 00:00:00 2001 From: Sergei Maertens Date: Mon, 13 May 2024 14:20:55 +0200 Subject: [PATCH 1/7] :fire: Delete compatibility layer for unsupported Django version(s) This shim was needed for Django versions <= 3.1 --- mozilla_django_oidc_db/compat.py | 6 ------ mozilla_django_oidc_db/models.py | 2 +- 2 files changed, 1 insertion(+), 7 deletions(-) delete mode 100644 mozilla_django_oidc_db/compat.py diff --git a/mozilla_django_oidc_db/compat.py b/mozilla_django_oidc_db/compat.py deleted file mode 100644 index 0ff3a07..0000000 --- a/mozilla_django_oidc_db/compat.py +++ /dev/null @@ -1,6 +0,0 @@ -# `classproperty` was moved to another module in Django 3.1 -# See https://github.com/django/django/blob/ca9872905559026af82000e46cde6f7dedc897b6/docs/releases/3.1.txt#L649 -try: - from django.utils.functional import classproperty -except ImportError: - from django.utils.decorators import classproperty diff --git a/mozilla_django_oidc_db/models.py b/mozilla_django_oidc_db/models.py index 7027de3..798c3ff 100644 --- a/mozilla_django_oidc_db/models.py +++ b/mozilla_django_oidc_db/models.py @@ -4,6 +4,7 @@ from django.core.exceptions import FieldDoesNotExist, ValidationError from django.db import models from django.utils.encoding import force_str +from django.utils.functional import classproperty from django.utils.translation import gettext_lazy as _ from django_jsonform.models.fields import ArrayField @@ -11,7 +12,6 @@ import mozilla_django_oidc_db.settings as oidc_settings -from .compat import classproperty from .fields import ClaimField From de5f6e36f261385041d72c2a9109636cfb37b4d2 Mon Sep 17 00:00:00 2001 From: Sergei Maertens Date: Mon, 13 May 2024 14:34:08 +0200 Subject: [PATCH 2/7] :recycle: [#99] Break out looking up settings into separate helper --- mozilla_django_oidc_db/config.py | 38 ++++++++++++++++++++++++++++++++ mozilla_django_oidc_db/mixins.py | 25 +++++++++------------ 2 files changed, 49 insertions(+), 14 deletions(-) create mode 100644 mozilla_django_oidc_db/config.py diff --git a/mozilla_django_oidc_db/config.py b/mozilla_django_oidc_db/config.py new file mode 100644 index 0000000..5d0d8d1 --- /dev/null +++ b/mozilla_django_oidc_db/config.py @@ -0,0 +1,38 @@ +""" +Helpers to work with (dynamic) OIDC configuration. + +The utilities here make it easier to work with configuration that lives on a +configuration model instance rather than in Django settings, while also handling +settings that are still defined in the django settings layer. +""" + +from typing import Any + +from mozilla_django_oidc.utils import import_from_settings + +from .models import OpenIDConnectConfigBase + + +def get_setting_from_config(config: OpenIDConnectConfigBase, attr: str, *args) -> Any: + """ + Look up a setting from the config record or fall back to Django settings. + + Django settings are defined as ``OIDC_SOME_SETTING``, in upper case, while our + model fields typically match the name, but in lower case. So, we look up if the + requested setting exists as an attribut on the configuration instance and use that + when provided, otherwise we fall back to the django settings module. + + .. note:: A setting may also be defined as a (calculated) property of some kind on + a/the configuration instance, rather than an explicit model field. That's why + we use ``hasattr`` checks rather than relying on + ``config._meta.get_field(some_field)``. + """ + attr_lowercase = attr.lower() + if hasattr(config, attr_lowercase): + # Workaround for OIDC_RP_IDP_SIGN_KEY being an empty string by default. + # mozilla-django-oidc explicitly checks if `OIDC_RP_IDP_SIGN_KEY` is not `None` + # https://github.com/mozilla/mozilla-django-oidc/blob/master/mozilla_django_oidc/auth.py#L189 + if (value_from_config := getattr(config, attr_lowercase)) == "": + return None + return value_from_config + return import_from_settings(attr, *args) diff --git a/mozilla_django_oidc_db/mixins.py b/mozilla_django_oidc_db/mixins.py index 4ae1038..c999c45 100644 --- a/mozilla_django_oidc_db/mixins.py +++ b/mozilla_django_oidc_db/mixins.py @@ -1,7 +1,7 @@ -from typing import ClassVar, Generic, TypeVar, cast - -from mozilla_django_oidc.utils import import_from_settings +import warnings +from typing import Any, ClassVar, Generic, TypeVar, cast +from .config import get_setting_from_config from .models import OpenIDConnectConfig, OpenIDConnectConfigBase T = TypeVar("T", bound=OpenIDConnectConfigBase) @@ -28,17 +28,8 @@ def refresh_config(self) -> None: if hasattr(self, "_solo_config"): del self._solo_config - def get_settings(self, attr, *args): - attr_lowercase = attr.lower() - if hasattr(self.config, attr_lowercase): - # Workaround for OIDC_RP_IDP_SIGN_KEY being an empty string by default. - # mozilla-django-oidc explicitly checks if `OIDC_RP_IDP_SIGN_KEY` is not `None` - # https://github.com/mozilla/mozilla-django-oidc/blob/master/mozilla_django_oidc/auth.py#L189 - value_from_config = getattr(self.config, attr_lowercase) - if value_from_config == "": - return None - return value_from_config - return import_from_settings(attr, *args) + def get_settings(self, attr: str, *args: Any): + return get_setting_from_config(self.config, attr, *args) class GetAttributeMixin: @@ -50,6 +41,12 @@ def __getattribute__(self, attr: str): if not attr.startswith("OIDC"): return super().__getattribute__(attr) + warnings.warn( + "GetAttributeMixin will be deprecated, instead use an explicit descriptor", + category=PendingDeprecationWarning, + stacklevel=2, + ) + try: default = super().__getattribute__(attr) except AttributeError: From e5fbd339970959bfe3f94c22c61e028899155ef9 Mon Sep 17 00:00:00 2001 From: Sergei Maertens Date: Mon, 13 May 2024 16:10:48 +0200 Subject: [PATCH 3/7] :sparkles: [#99] Make AuthenticationRequestView more general purpose Ported the refactor from Open Forms. The library now ships an OIDCInit view that can handle multiple config classes (and in the future: instances of a single model), which all have tailored behaviours. The default AuthenticationRequestView is now 'just a flavour' of this generic behaviour. We track some additional state and perform additional redirect URL validation for those situations where stricter behaviour of the OIDC flow is desired. --- mozilla_django_oidc_db/exceptions.py | 2 + mozilla_django_oidc_db/views.py | 191 +++++++++++++++++++++++++- setup.cfg | 17 +++ tests/test_init_flow.py | 49 +++++++ tests/test_init_flow_custom_config.py | 90 ++++++++++++ 5 files changed, 345 insertions(+), 4 deletions(-) create mode 100644 mozilla_django_oidc_db/exceptions.py create mode 100644 tests/test_init_flow.py create mode 100644 tests/test_init_flow_custom_config.py diff --git a/mozilla_django_oidc_db/exceptions.py b/mozilla_django_oidc_db/exceptions.py new file mode 100644 index 0000000..e4e8527 --- /dev/null +++ b/mozilla_django_oidc_db/exceptions.py @@ -0,0 +1,2 @@ +class OIDCProviderOutage(Exception): + pass diff --git a/mozilla_django_oidc_db/views.py b/mozilla_django_oidc_db/views.py index c99bca3..8cd1c1c 100644 --- a/mozilla_django_oidc_db/views.py +++ b/mozilla_django_oidc_db/views.py @@ -1,9 +1,13 @@ import logging +from typing import Any, ClassVar, Generic, TypeVar, cast +from urllib.parse import parse_qs, urlsplit from django.contrib import admin -from django.core.exceptions import PermissionDenied, ValidationError +from django.core.exceptions import DisallowedRedirect, PermissionDenied, ValidationError from django.db import IntegrityError, transaction +from django.http import HttpRequest, HttpResponseRedirect from django.urls import reverse_lazy +from django.utils.http import url_has_allowed_host_and_scheme from django.views.generic import TemplateView from mozilla_django_oidc.views import ( @@ -11,10 +15,30 @@ OIDCAuthenticationRequestView as _OIDCAuthenticationRequestView, ) -from .mixins import SoloConfigMixin +from .config import get_setting_from_config +from .models import OpenIDConnectConfig, OpenIDConnectConfigBase logger = logging.getLogger(__name__) + OIDC_ERROR_SESSION_KEY = "oidc-error" +""" +Session key where to store authentication error messages. + +During the callback flow, if any errors are encountered, they are stored in the session +under this key so that :class:`AdminLoginFailure` can read and display them to the +end-user. +""" + +RETURN_URL_SESSION_KEY = "oidc-db_redirect_next" +""" +Session key for the "next" URL to redirect the user to. + +This is the equivalent of the "oidc_login_next" session key from mozilla_django_oidc, +which we deliberately do not rely on as their usage may change and it is private API. + +In some situations the value of this session key needs to be used as base to properly +display problems (used in the ``failure_url`` flow of the callback view). +""" def get_exception_message(exc: Exception) -> str: @@ -71,5 +95,164 @@ def get_context_data(self, **kwargs): return context -class OIDCAuthenticationRequestView(SoloConfigMixin, _OIDCAuthenticationRequestView): - pass +T = TypeVar("T", bound=OpenIDConnectConfigBase) + + +class OIDCInit(Generic[T], _OIDCAuthenticationRequestView): + """ + A 'view' to start an OIDC authentication flow. + + This view class is parametrized with the config model/class to retrieve the + specific configuration, such as the identity provider endpoint to redirect the + user to. + + This view is not necessarily meant to be exposed directly via a URL pattern, but + rather specific views are to be created from it, e.g.: + + .. code-block:: python + + >>> digid_init = OIDCInit.as_view(config_class=OpenIDConnectPublicConfig) + >>> redirect_response = digid_init(request) + # Redirect to some keycloak instance, for example. + + These concrete views are intended to be wrapped by your own views so that you can + supply the ``return_url`` parameter: + + .. code-block:: python + + def my_digid_login(request): + return digid_init(request, return_url=request.GET["next"]) + + Compared to :class:`mozilla_django_oidc.views.OIDCAuthenticationRequestView`, some + extra actions are performed: + + * Any Keycloak IdP hint is added, if configured + * The ``return_url`` is validated against unsafe redirects + * The availability of the identity provider endpoint can be checked, if it's not + available, the :class:`mozilla_django_oidc_db.exceptions.OIDCProviderOutage` + exception is raised. Note that your own code needs to handle this appropriately! + """ + + _config: T + config_class: ClassVar[type[OpenIDConnectConfigBase]] = OpenIDConnectConfigBase + """ + The config model/class to get the endpoints/credentials from. + + Specify this as a kwarg in the ``as_view(config_class=...)`` class method. + """ + + allow_next_from_query: bool = False + """ + Specify if the url-to-redirect-to may be provided as a query string parameter. + + For OIDC auth in the admin, you want to enable this to make URLs like + ``/oidc/authenticate/?next=/admin/`` work as expected. For more advanced flows, + you may want explicit control over this URL via your own wrapper view: + + .. code-block:: python + + digid_init = OIDCInit.as_view( + config_class=OpenIDConnectPublicConfig, allow_next_from_query=False + ) + + def my_digid_login(request): + return digid_init(request, return_url="/some-fixed-url") + """ + + def get_settings(self, attr: str, *args: Any): # type: ignore + """ + Look up the request setting from the database config. + + For the duration of the request, the configuration instance is cached on the + view. + """ + if (config := getattr(self, "_config", None)) is None: + # django-solo and type checking is challenging, but a new release is on the + # way and should fix that :fingers_crossed: + config = cast(T, self.config_class.get_solo()) + self._config = config + return get_setting_from_config(config, attr, *args) + + def get( + self, request: HttpRequest, return_url: str = "", *args, **kwargs + ) -> HttpResponseRedirect: + if not self.allow_next_from_query: + self._validate_return_url(request, return_url=return_url) + + self.check_idp_availability() + + response = super().get(request, *args, **kwargs) + + # update the return_url value with what the upstream library extracted from the + # GET query parameters. + if self.allow_next_from_query: + return_url = request.session["oidc_login_next"] + + # We add our own key to keep track of the redirect URL. In the case of + # authentication failure (or canceled logins), the session is cleared by the + # upstream library, so in the callback view we store this URL so that we know + # where to redirect with the error information. + request.session[RETURN_URL_SESSION_KEY] = return_url + + # mozilla-django-oidc grabs this from request.GET and since that is not mutable, + # it's easiest to just override the session key with the correct value. + request.session["oidc_login_next"] = return_url + + # Store which config class to use in the state. We can not simply pass this as + # a querystring parameter appended to redirect_uri, as these are likely to be + # strictly validated. We must grab the state from the redirect Location. + # This config reference is later used in the authentication callback view and + # the authentication backend. + query = parse_qs(urlsplit(response.url).query) + state_params: list[str] = query["state"] + assert len(state_params) == 1, "Expected only a single state parameter" + state_key = state_params[0] + options = self.config_class._meta + + # update the state. the parent class caused the session to be marked as modified, + # so django's middleware will take care of persisting this to the session backend. + state = request.session["oidc_states"][state_key] + state["config_class"] = f"{options.app_label}.{options.object_name}" + + return response + + @staticmethod + def _validate_return_url(request: HttpRequest, return_url: str) -> None: + """ + Validate that the return URL meets the requirements. + + 1. A non-empty value needs to be provided. + 2. The URL must be a safe redirect - only internal redirects are allowed. + """ + if not return_url: + raise ValueError("You must pass a return URL") + + url_is_safe = url_has_allowed_host_and_scheme( + url=return_url, + allowed_hosts=request.get_host(), + require_https=request.is_secure(), + ) + if not url_is_safe: + raise DisallowedRedirect(f"Can't redirect to '{return_url}'") + + def check_idp_availability(self) -> None: + """ + Hook for subclasses. + + Raise :class:`OIDCProviderOutage` if the Identity Provider is not available. + """ + pass + + def get_extra_params(self, request: HttpRequest) -> dict[str, str]: + """ + Add a keycloak identity provider hint if configured. + """ + extra = super().get_extra_params(request) + if kc_idp_hint := self.get_settings("OIDC_KEYCLOAK_IDP_HINT", ""): + extra["kc_idp_hint"] = kc_idp_hint + return extra + + +class OIDCAuthenticationRequestView(OIDCInit[OpenIDConnectConfig]): + config_class = OpenIDConnectConfig + allow_next_from_query = True diff --git a/setup.cfg b/setup.cfg index 04d1169..ea52488 100644 --- a/setup.cfg +++ b/setup.cfg @@ -98,3 +98,20 @@ exclude=env,.tox,doc [flake8] max-line-length=88 exclude=env,.tox,doc + +[coverage:run] +branch = true +source = mozilla_django_oidc_db +omit = + # migrations run while django initializes the test db + "*/migrations/*", + +[coverage:report] +exclude_also = + if (typing\\.)?TYPE_CHECKING: + @(typing\\.)?overload + class .*\\(.*Protocol.*\\): + @(abc\\.)?abstractmethod + raise NotImplementedError + \\.\\.\\. + pass diff --git a/tests/test_init_flow.py b/tests/test_init_flow.py new file mode 100644 index 0000000..f9d0b53 --- /dev/null +++ b/tests/test_init_flow.py @@ -0,0 +1,49 @@ +""" +Test the OIDC Authenticaton Request flow with our custom views. +""" + +from urllib.parse import parse_qs, urlsplit + +from django.urls import reverse + + +def test_default_config_flow(keycloak_config, settings, client): + settings.OIDC_AUTHENTICATE_CLASS = ( + "mozilla_django_oidc_db.views.OIDCAuthenticationRequestView" + ) + start_url = reverse("oidc_authentication_init") + assert start_url == "/oidc/authenticate/" + + response = client.get(start_url, {"next": "/admin/"}) + + # check that the view redirects to the identity provider + assert response.status_code == 302 + parsed_url = urlsplit(response.url) + assert parsed_url.scheme == "http" + assert parsed_url.netloc == "localhost:8080" + assert parsed_url.path == "/realms/test/protocol/openid-connect/auth" + + # introspect state + state_key = parse_qs(parsed_url.query)["state"][0] + state = client.session["oidc_states"][state_key] + assert state["config_class"] == "mozilla_django_oidc_db.OpenIDConnectConfig" + # upstream library + assert client.session["oidc_login_next"] == "/admin/" + # our own addition + assert client.session["oidc-db_redirect_next"] == "/admin/" + + +def test_keycloak_idp_hint_via_settings(keycloak_config, settings, client): + settings.OIDC_AUTHENTICATE_CLASS = ( + "mozilla_django_oidc_db.views.OIDCAuthenticationRequestView" + ) + settings.OIDC_KEYCLOAK_IDP_HINT = "keycloak-idp1" + start_url = reverse("oidc_authentication_init") + + response = client.get(start_url) + + assert response.status_code == 302 + parsed_url = urlsplit(response.url) + + query = parse_qs(parsed_url.query) + assert query["kc_idp_hint"] == ["keycloak-idp1"] diff --git a/tests/test_init_flow_custom_config.py b/tests/test_init_flow_custom_config.py new file mode 100644 index 0000000..79be6ef --- /dev/null +++ b/tests/test_init_flow_custom_config.py @@ -0,0 +1,90 @@ +""" +Test the init flow through a custom view and config. +""" + +from urllib.parse import parse_qs, urlsplit + +from django.contrib.sessions.backends.db import SessionStore +from django.core.exceptions import DisallowedRedirect +from django.test import RequestFactory + +import pytest + +from mozilla_django_oidc_db.models import OpenIDConnectConfig +from mozilla_django_oidc_db.views import OIDCInit + +pytestmark = [pytest.mark.django_db] + + +@pytest.fixture +def auth_request(rf: RequestFactory): + request = rf.get("/some-auth", {"next": "/ignored"}) + session = SessionStore() + session.save() + request.session = session + return request + + +# Use a proxy model to modify behaviour without needing migrations/models machinery. + + +class static_setting: + def __init__(self, val): + self.val = val + + def __get__(self, obj, objtype): + return self.val + + def __set__(self, obj, val): + pass + + +class CustomConfig(OpenIDConnectConfig): + class Meta: + proxy = True + app_label = "mozilla_django_oidc_db" + + enabled = static_setting(True) + oidc_rp_client_id = static_setting("fixed_client_id") + oidc_rp_client_secret = static_setting("supersecret") + oidc_op_authorization_endpoint = static_setting("https://example.com/oidc/auth") + + +oidc_init = OIDCInit.as_view(config_class=CustomConfig) + + +def test_redirects_to_oidc_provider(auth_request): + response = oidc_init(auth_request, return_url="/fixed-next") + + assert response.status_code == 302 + parsed_url = urlsplit(response.url) + assert parsed_url.scheme == "https" + assert parsed_url.netloc == "example.com" + assert parsed_url.path == "/oidc/auth" + + # introspect state + state_key = parse_qs(parsed_url.query)["state"][0] + state = auth_request.session["oidc_states"][state_key] + assert state["config_class"] == "mozilla_django_oidc_db.CustomConfig" + # upstream library + assert auth_request.session["oidc_login_next"] == "/fixed-next" + # our own addition + assert auth_request.session["oidc-db_redirect_next"] == "/fixed-next" + + +def test_suspicious_return_url(auth_request): + with pytest.raises(DisallowedRedirect): + oidc_init(auth_request, return_url="http://evil.com/steal-my-data") + + +@pytest.mark.parametrize( + "get_kwargs", + ( + {}, + {"return_url": ""}, + {"return_url": None}, + ), +) +def test_forgotten_return_url(auth_request, get_kwargs): + with pytest.raises(ValueError): + oidc_init(auth_request, **get_kwargs) From 3a1f543575a92f9b28bc26a03736000bbcd7c5b6 Mon Sep 17 00:00:00 2001 From: Sergei Maertens Date: Mon, 13 May 2024 16:16:36 +0200 Subject: [PATCH 4/7] :pencil: [#99] Update API reference documentation --- docs/conf.py | 3 +++ docs/reference.rst | 7 +++++++ mozilla_django_oidc_db/views.py | 31 ++++++++++++++++++++++--------- 3 files changed, 32 insertions(+), 9 deletions(-) diff --git a/docs/conf.py b/docs/conf.py index 62cc9fb..e71f8d1 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -14,10 +14,13 @@ import sys from pathlib import Path +import django + _root_dir = Path(__file__).parent.parent.resolve() sys.path.insert(0, str(_root_dir)) os.environ.setdefault("DJANGO_SETTINGS_MODULE", "testapp.settings") +django.setup() # -- Project information ----------------------------------------------------- diff --git a/docs/reference.rst b/docs/reference.rst index 4bd0f85..53643a6 100644 --- a/docs/reference.rst +++ b/docs/reference.rst @@ -4,6 +4,13 @@ Reference Public API documentation. +Views +===== + +.. automodule:: mozilla_django_oidc_db.views + :members: + + Utils ===== diff --git a/mozilla_django_oidc_db/views.py b/mozilla_django_oidc_db/views.py index 8cd1c1c..8b9f18d 100644 --- a/mozilla_django_oidc_db/views.py +++ b/mozilla_django_oidc_db/views.py @@ -20,7 +20,7 @@ logger = logging.getLogger(__name__) -OIDC_ERROR_SESSION_KEY = "oidc-error" +_OIDC_ERROR_SESSION_KEY = "oidc-error" """ Session key where to store authentication error messages. @@ -29,7 +29,7 @@ end-user. """ -RETURN_URL_SESSION_KEY = "oidc-db_redirect_next" +_RETURN_URL_SESSION_KEY = "oidc-db_redirect_next" """ Session key for the "next" URL to redirect the user to. @@ -68,11 +68,11 @@ def get(self, request): exc_info=exc, ) exc_message = get_exception_message(exc) - request.session[OIDC_ERROR_SESSION_KEY] = exc_message + request.session[_OIDC_ERROR_SESSION_KEY] = exc_message return self.login_failure() else: - if OIDC_ERROR_SESSION_KEY in request.session: - del request.session[OIDC_ERROR_SESSION_KEY] + if _OIDC_ERROR_SESSION_KEY in request.session: + del request.session[_OIDC_ERROR_SESSION_KEY] return response @@ -84,14 +84,14 @@ class AdminLoginFailure(TemplateView): template_name = "admin/oidc_failure.html" def dispatch(self, request, *args, **kwargs): - if OIDC_ERROR_SESSION_KEY not in request.session: + if _OIDC_ERROR_SESSION_KEY not in request.session: raise PermissionDenied() return super().dispatch(request, *args, **kwargs) def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) context.update(admin.site.each_context(self.request)) - context["oidc_error"] = self.request.session[OIDC_ERROR_SESSION_KEY] + context["oidc_error"] = self.request.session[_OIDC_ERROR_SESSION_KEY] return context @@ -159,7 +159,7 @@ def my_digid_login(request): return digid_init(request, return_url="/some-fixed-url") """ - def get_settings(self, attr: str, *args: Any): # type: ignore + def get_settings(self, attr: str, *args: Any) -> Any: # type: ignore """ Look up the request setting from the database config. @@ -192,7 +192,7 @@ def get( # authentication failure (or canceled logins), the session is cleared by the # upstream library, so in the callback view we store this URL so that we know # where to redirect with the error information. - request.session[RETURN_URL_SESSION_KEY] = return_url + request.session[_RETURN_URL_SESSION_KEY] = return_url # mozilla-django-oidc grabs this from request.GET and since that is not mutable, # it's easiest to just override the session key with the correct value. @@ -254,5 +254,18 @@ def get_extra_params(self, request: HttpRequest) -> dict[str, str]: class OIDCAuthenticationRequestView(OIDCInit[OpenIDConnectConfig]): + """ + Start an OIDC authentication flow. + + This view is pre-configured to use the OIDC configuration included in this library, + intended for admin authentication. Enable it in your Django settings with: + + .. code-block:: python + + OIDC_AUTHENTICATE_CLASS = ( + "mozilla_django_oidc_db.views.OIDCAuthenticationRequestView" + ) + """ + config_class = OpenIDConnectConfig allow_next_from_query = True From 6bc693a45afa9972397d1056e15c315bbcc10fb8 Mon Sep 17 00:00:00 2001 From: Sergei Maertens Date: Mon, 13 May 2024 16:22:42 +0200 Subject: [PATCH 5/7] :white_check_mark: [#99] Add test for IPD availability check mechanism --- tests/test_init_flow_custom_config.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/tests/test_init_flow_custom_config.py b/tests/test_init_flow_custom_config.py index 79be6ef..e96a229 100644 --- a/tests/test_init_flow_custom_config.py +++ b/tests/test_init_flow_custom_config.py @@ -10,6 +10,7 @@ import pytest +from mozilla_django_oidc_db.exceptions import OIDCProviderOutage from mozilla_django_oidc_db.models import OpenIDConnectConfig from mozilla_django_oidc_db.views import OIDCInit @@ -88,3 +89,18 @@ def test_suspicious_return_url(auth_request): def test_forgotten_return_url(auth_request, get_kwargs): with pytest.raises(ValueError): oidc_init(auth_request, **get_kwargs) + + +class IDPCheckInitView(OIDCInit): + def check_idp_availability(self) -> None: + raise OIDCProviderOutage("The internet is bwoken.") + + +oidc_init_with_idp_check = IDPCheckInitView.as_view( + config_class=CustomConfig, allow_next_from_query=True +) + + +def test_idp_check_mechanism(auth_request): + with pytest.raises(OIDCProviderOutage): + oidc_init_with_idp_check(auth_request) From f2956a653944a6ce866ea8f92be470a615177167 Mon Sep 17 00:00:00 2001 From: Sergei Maertens Date: Mon, 13 May 2024 16:29:06 +0200 Subject: [PATCH 6/7] :green_heart: [#99] Record VCR cassettes --- .../test_default_config_flow.yaml | 39 +++++++++++++++++++ .../test_keycloak_idp_hint_via_settings.yaml | 39 +++++++++++++++++++ tests/conftest.py | 12 ++++-- tests/test_init_flow.py | 4 ++ 4 files changed, 91 insertions(+), 3 deletions(-) create mode 100644 tests/cassettes/test_init_flow/test_default_config_flow.yaml create mode 100644 tests/cassettes/test_init_flow/test_keycloak_idp_hint_via_settings.yaml diff --git a/tests/cassettes/test_init_flow/test_default_config_flow.yaml b/tests/cassettes/test_init_flow/test_default_config_flow.yaml new file mode 100644 index 0000000..4da63d6 --- /dev/null +++ b/tests/cassettes/test_init_flow/test_default_config_flow.yaml @@ -0,0 +1,39 @@ +interactions: +- request: + body: null + headers: + Accept: + - '*/*' + Accept-Encoding: + - gzip, deflate + Connection: + - keep-alive + User-Agent: + - python-requests/2.31.0 + method: GET + uri: http://localhost:8080/realms/test/.well-known/openid-configuration + response: + body: + string: '{"issuer":"http://localhost:8080/realms/test","authorization_endpoint":"http://localhost:8080/realms/test/protocol/openid-connect/auth","token_endpoint":"http://localhost:8080/realms/test/protocol/openid-connect/token","introspection_endpoint":"http://localhost:8080/realms/test/protocol/openid-connect/token/introspect","userinfo_endpoint":"http://localhost:8080/realms/test/protocol/openid-connect/userinfo","end_session_endpoint":"http://localhost:8080/realms/test/protocol/openid-connect/logout","frontchannel_logout_session_supported":true,"frontchannel_logout_supported":true,"jwks_uri":"http://localhost:8080/realms/test/protocol/openid-connect/certs","check_session_iframe":"http://localhost:8080/realms/test/protocol/openid-connect/login-status-iframe.html","grant_types_supported":["authorization_code","implicit","refresh_token","password","client_credentials","urn:openid:params:grant-type:ciba","urn:ietf:params:oauth:grant-type:device_code"],"acr_values_supported":["0","1"],"response_types_supported":["code","none","id_token","token","id_token + token","code id_token","code token","code id_token token"],"subject_types_supported":["public","pairwise"],"id_token_signing_alg_values_supported":["PS384","ES384","RS384","HS256","HS512","ES256","RS256","HS384","ES512","PS256","PS512","RS512"],"id_token_encryption_alg_values_supported":["RSA-OAEP","RSA-OAEP-256","RSA1_5"],"id_token_encryption_enc_values_supported":["A256GCM","A192GCM","A128GCM","A128CBC-HS256","A192CBC-HS384","A256CBC-HS512"],"userinfo_signing_alg_values_supported":["PS384","ES384","RS384","HS256","HS512","ES256","RS256","HS384","ES512","PS256","PS512","RS512","none"],"userinfo_encryption_alg_values_supported":["RSA-OAEP","RSA-OAEP-256","RSA1_5"],"userinfo_encryption_enc_values_supported":["A256GCM","A192GCM","A128GCM","A128CBC-HS256","A192CBC-HS384","A256CBC-HS512"],"request_object_signing_alg_values_supported":["PS384","ES384","RS384","HS256","HS512","ES256","RS256","HS384","ES512","PS256","PS512","RS512","none"],"request_object_encryption_alg_values_supported":["RSA-OAEP","RSA-OAEP-256","RSA1_5"],"request_object_encryption_enc_values_supported":["A256GCM","A192GCM","A128GCM","A128CBC-HS256","A192CBC-HS384","A256CBC-HS512"],"response_modes_supported":["query","fragment","form_post","query.jwt","fragment.jwt","form_post.jwt","jwt"],"registration_endpoint":"http://localhost:8080/realms/test/clients-registrations/openid-connect","token_endpoint_auth_methods_supported":["private_key_jwt","client_secret_basic","client_secret_post","tls_client_auth","client_secret_jwt"],"token_endpoint_auth_signing_alg_values_supported":["PS384","ES384","RS384","HS256","HS512","ES256","RS256","HS384","ES512","PS256","PS512","RS512"],"introspection_endpoint_auth_methods_supported":["private_key_jwt","client_secret_basic","client_secret_post","tls_client_auth","client_secret_jwt"],"introspection_endpoint_auth_signing_alg_values_supported":["PS384","ES384","RS384","HS256","HS512","ES256","RS256","HS384","ES512","PS256","PS512","RS512"],"authorization_signing_alg_values_supported":["PS384","ES384","RS384","HS256","HS512","ES256","RS256","HS384","ES512","PS256","PS512","RS512"],"authorization_encryption_alg_values_supported":["RSA-OAEP","RSA-OAEP-256","RSA1_5"],"authorization_encryption_enc_values_supported":["A256GCM","A192GCM","A128GCM","A128CBC-HS256","A192CBC-HS384","A256CBC-HS512"],"claims_supported":["aud","sub","iss","auth_time","name","given_name","family_name","preferred_username","email","acr"],"claim_types_supported":["normal"],"claims_parameter_supported":true,"scopes_supported":["openid","email","roles","phone","profile","address","kvk","web-origins","microprofile-jwt","acr","offline_access","bsn"],"request_parameter_supported":true,"request_uri_parameter_supported":true,"require_request_uri_registration":true,"code_challenge_methods_supported":["plain","S256"],"tls_client_certificate_bound_access_tokens":true,"revocation_endpoint":"http://localhost:8080/realms/test/protocol/openid-connect/revoke","revocation_endpoint_auth_methods_supported":["private_key_jwt","client_secret_basic","client_secret_post","tls_client_auth","client_secret_jwt"],"revocation_endpoint_auth_signing_alg_values_supported":["PS384","ES384","RS384","HS256","HS512","ES256","RS256","HS384","ES512","PS256","PS512","RS512"],"backchannel_logout_supported":true,"backchannel_logout_session_supported":true,"device_authorization_endpoint":"http://localhost:8080/realms/test/protocol/openid-connect/auth/device","backchannel_token_delivery_modes_supported":["poll","ping"],"backchannel_authentication_endpoint":"http://localhost:8080/realms/test/protocol/openid-connect/ext/ciba/auth","backchannel_authentication_request_signing_alg_values_supported":["PS384","ES384","RS384","ES256","RS256","ES512","PS256","PS512","RS512"],"require_pushed_authorization_requests":false,"pushed_authorization_request_endpoint":"http://localhost:8080/realms/test/protocol/openid-connect/ext/par/request","mtls_endpoint_aliases":{"token_endpoint":"http://localhost:8080/realms/test/protocol/openid-connect/token","revocation_endpoint":"http://localhost:8080/realms/test/protocol/openid-connect/revoke","introspection_endpoint":"http://localhost:8080/realms/test/protocol/openid-connect/token/introspect","device_authorization_endpoint":"http://localhost:8080/realms/test/protocol/openid-connect/auth/device","registration_endpoint":"http://localhost:8080/realms/test/clients-registrations/openid-connect","userinfo_endpoint":"http://localhost:8080/realms/test/protocol/openid-connect/userinfo","pushed_authorization_request_endpoint":"http://localhost:8080/realms/test/protocol/openid-connect/ext/par/request","backchannel_authentication_endpoint":"http://localhost:8080/realms/test/protocol/openid-connect/ext/ciba/auth"},"authorization_response_iss_parameter_supported":true}' + headers: + Cache-Control: + - no-cache, must-revalidate, no-transform, no-store + Content-Type: + - application/json;charset=UTF-8 + Referrer-Policy: + - no-referrer + Strict-Transport-Security: + - max-age=31536000; includeSubDomains + X-Content-Type-Options: + - nosniff + X-Frame-Options: + - SAMEORIGIN + X-XSS-Protection: + - 1; mode=block + content-length: + - '5847' + status: + code: 200 + message: OK +version: 1 diff --git a/tests/cassettes/test_init_flow/test_keycloak_idp_hint_via_settings.yaml b/tests/cassettes/test_init_flow/test_keycloak_idp_hint_via_settings.yaml new file mode 100644 index 0000000..4da63d6 --- /dev/null +++ b/tests/cassettes/test_init_flow/test_keycloak_idp_hint_via_settings.yaml @@ -0,0 +1,39 @@ +interactions: +- request: + body: null + headers: + Accept: + - '*/*' + Accept-Encoding: + - gzip, deflate + Connection: + - keep-alive + User-Agent: + - python-requests/2.31.0 + method: GET + uri: http://localhost:8080/realms/test/.well-known/openid-configuration + response: + body: + string: '{"issuer":"http://localhost:8080/realms/test","authorization_endpoint":"http://localhost:8080/realms/test/protocol/openid-connect/auth","token_endpoint":"http://localhost:8080/realms/test/protocol/openid-connect/token","introspection_endpoint":"http://localhost:8080/realms/test/protocol/openid-connect/token/introspect","userinfo_endpoint":"http://localhost:8080/realms/test/protocol/openid-connect/userinfo","end_session_endpoint":"http://localhost:8080/realms/test/protocol/openid-connect/logout","frontchannel_logout_session_supported":true,"frontchannel_logout_supported":true,"jwks_uri":"http://localhost:8080/realms/test/protocol/openid-connect/certs","check_session_iframe":"http://localhost:8080/realms/test/protocol/openid-connect/login-status-iframe.html","grant_types_supported":["authorization_code","implicit","refresh_token","password","client_credentials","urn:openid:params:grant-type:ciba","urn:ietf:params:oauth:grant-type:device_code"],"acr_values_supported":["0","1"],"response_types_supported":["code","none","id_token","token","id_token + token","code id_token","code token","code id_token token"],"subject_types_supported":["public","pairwise"],"id_token_signing_alg_values_supported":["PS384","ES384","RS384","HS256","HS512","ES256","RS256","HS384","ES512","PS256","PS512","RS512"],"id_token_encryption_alg_values_supported":["RSA-OAEP","RSA-OAEP-256","RSA1_5"],"id_token_encryption_enc_values_supported":["A256GCM","A192GCM","A128GCM","A128CBC-HS256","A192CBC-HS384","A256CBC-HS512"],"userinfo_signing_alg_values_supported":["PS384","ES384","RS384","HS256","HS512","ES256","RS256","HS384","ES512","PS256","PS512","RS512","none"],"userinfo_encryption_alg_values_supported":["RSA-OAEP","RSA-OAEP-256","RSA1_5"],"userinfo_encryption_enc_values_supported":["A256GCM","A192GCM","A128GCM","A128CBC-HS256","A192CBC-HS384","A256CBC-HS512"],"request_object_signing_alg_values_supported":["PS384","ES384","RS384","HS256","HS512","ES256","RS256","HS384","ES512","PS256","PS512","RS512","none"],"request_object_encryption_alg_values_supported":["RSA-OAEP","RSA-OAEP-256","RSA1_5"],"request_object_encryption_enc_values_supported":["A256GCM","A192GCM","A128GCM","A128CBC-HS256","A192CBC-HS384","A256CBC-HS512"],"response_modes_supported":["query","fragment","form_post","query.jwt","fragment.jwt","form_post.jwt","jwt"],"registration_endpoint":"http://localhost:8080/realms/test/clients-registrations/openid-connect","token_endpoint_auth_methods_supported":["private_key_jwt","client_secret_basic","client_secret_post","tls_client_auth","client_secret_jwt"],"token_endpoint_auth_signing_alg_values_supported":["PS384","ES384","RS384","HS256","HS512","ES256","RS256","HS384","ES512","PS256","PS512","RS512"],"introspection_endpoint_auth_methods_supported":["private_key_jwt","client_secret_basic","client_secret_post","tls_client_auth","client_secret_jwt"],"introspection_endpoint_auth_signing_alg_values_supported":["PS384","ES384","RS384","HS256","HS512","ES256","RS256","HS384","ES512","PS256","PS512","RS512"],"authorization_signing_alg_values_supported":["PS384","ES384","RS384","HS256","HS512","ES256","RS256","HS384","ES512","PS256","PS512","RS512"],"authorization_encryption_alg_values_supported":["RSA-OAEP","RSA-OAEP-256","RSA1_5"],"authorization_encryption_enc_values_supported":["A256GCM","A192GCM","A128GCM","A128CBC-HS256","A192CBC-HS384","A256CBC-HS512"],"claims_supported":["aud","sub","iss","auth_time","name","given_name","family_name","preferred_username","email","acr"],"claim_types_supported":["normal"],"claims_parameter_supported":true,"scopes_supported":["openid","email","roles","phone","profile","address","kvk","web-origins","microprofile-jwt","acr","offline_access","bsn"],"request_parameter_supported":true,"request_uri_parameter_supported":true,"require_request_uri_registration":true,"code_challenge_methods_supported":["plain","S256"],"tls_client_certificate_bound_access_tokens":true,"revocation_endpoint":"http://localhost:8080/realms/test/protocol/openid-connect/revoke","revocation_endpoint_auth_methods_supported":["private_key_jwt","client_secret_basic","client_secret_post","tls_client_auth","client_secret_jwt"],"revocation_endpoint_auth_signing_alg_values_supported":["PS384","ES384","RS384","HS256","HS512","ES256","RS256","HS384","ES512","PS256","PS512","RS512"],"backchannel_logout_supported":true,"backchannel_logout_session_supported":true,"device_authorization_endpoint":"http://localhost:8080/realms/test/protocol/openid-connect/auth/device","backchannel_token_delivery_modes_supported":["poll","ping"],"backchannel_authentication_endpoint":"http://localhost:8080/realms/test/protocol/openid-connect/ext/ciba/auth","backchannel_authentication_request_signing_alg_values_supported":["PS384","ES384","RS384","ES256","RS256","ES512","PS256","PS512","RS512"],"require_pushed_authorization_requests":false,"pushed_authorization_request_endpoint":"http://localhost:8080/realms/test/protocol/openid-connect/ext/par/request","mtls_endpoint_aliases":{"token_endpoint":"http://localhost:8080/realms/test/protocol/openid-connect/token","revocation_endpoint":"http://localhost:8080/realms/test/protocol/openid-connect/revoke","introspection_endpoint":"http://localhost:8080/realms/test/protocol/openid-connect/token/introspect","device_authorization_endpoint":"http://localhost:8080/realms/test/protocol/openid-connect/auth/device","registration_endpoint":"http://localhost:8080/realms/test/clients-registrations/openid-connect","userinfo_endpoint":"http://localhost:8080/realms/test/protocol/openid-connect/userinfo","pushed_authorization_request_endpoint":"http://localhost:8080/realms/test/protocol/openid-connect/ext/par/request","backchannel_authentication_endpoint":"http://localhost:8080/realms/test/protocol/openid-connect/ext/ciba/auth"},"authorization_response_iss_parameter_supported":true}' + headers: + Cache-Control: + - no-cache, must-revalidate, no-transform, no-store + Content-Type: + - application/json;charset=UTF-8 + Referrer-Policy: + - no-referrer + Strict-Transport-Security: + - max-age=31536000; includeSubDomains + X-Content-Type-Options: + - nosniff + X-Frame-Options: + - SAMEORIGIN + X-XSS-Protection: + - 1; mode=block + content-length: + - '5847' + status: + code: 200 + message: OK +version: 1 diff --git a/tests/conftest.py b/tests/conftest.py index 3fc39c0..f830986 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,9 +1,11 @@ -from typing import Iterator +from __future__ import annotations + +from typing import TYPE_CHECKING, Iterator import pytest -from mozilla_django_oidc_db.forms import OpenIDConnectConfigForm -from mozilla_django_oidc_db.models import OpenIDConnectConfig, get_default_scopes +if TYPE_CHECKING: + from mozilla_django_oidc_db.models import OpenIDConnectConfig KEYCLOAK_BASE_URL = "http://localhost:8080/realms/test/" @@ -31,6 +33,10 @@ def keycloak_config(db) -> Iterator[OpenIDConnectConfig]: docker-compose up -d """ + # local imports to so that `pytest --help` can load this file + from mozilla_django_oidc_db.forms import OpenIDConnectConfigForm + from mozilla_django_oidc_db.models import OpenIDConnectConfig, get_default_scopes + endpoints = OpenIDConnectConfigForm.get_endpoints_from_discovery(KEYCLOAK_BASE_URL) config, _ = OpenIDConnectConfig.objects.update_or_create( diff --git a/tests/test_init_flow.py b/tests/test_init_flow.py index f9d0b53..9dc110a 100644 --- a/tests/test_init_flow.py +++ b/tests/test_init_flow.py @@ -6,7 +6,10 @@ from django.urls import reverse +import pytest + +@pytest.mark.vcr def test_default_config_flow(keycloak_config, settings, client): settings.OIDC_AUTHENTICATE_CLASS = ( "mozilla_django_oidc_db.views.OIDCAuthenticationRequestView" @@ -33,6 +36,7 @@ def test_default_config_flow(keycloak_config, settings, client): assert client.session["oidc-db_redirect_next"] == "/admin/" +@pytest.mark.vcr def test_keycloak_idp_hint_via_settings(keycloak_config, settings, client): settings.OIDC_AUTHENTICATE_CLASS = ( "mozilla_django_oidc_db.views.OIDCAuthenticationRequestView" From 1a033f8062f72fd4ae74ad2f23a765307f9eac91 Mon Sep 17 00:00:00 2001 From: Sergei Maertens Date: Tue, 14 May 2024 10:07:14 +0200 Subject: [PATCH 7/7] :truck: [#99] Move fixture to confest.py for generic usage --- tests/conftest.py | 12 ++++++++++++ tests/test_init_flow_custom_config.py | 15 +-------------- 2 files changed, 13 insertions(+), 14 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index f830986..4296c2a 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -2,6 +2,9 @@ from typing import TYPE_CHECKING, Iterator +from django.contrib.sessions.backends.db import SessionStore +from django.test import RequestFactory + import pytest if TYPE_CHECKING: @@ -57,3 +60,12 @@ def keycloak_config(db) -> Iterator[OpenIDConnectConfig]: yield config OpenIDConnectConfig.clear_cache() + + +@pytest.fixture +def auth_request(rf: RequestFactory): + request = rf.get("/some-auth", {"next": "/ignored"}) + session = SessionStore() + session.save() + request.session = session + return request diff --git a/tests/test_init_flow_custom_config.py b/tests/test_init_flow_custom_config.py index e96a229..7772357 100644 --- a/tests/test_init_flow_custom_config.py +++ b/tests/test_init_flow_custom_config.py @@ -4,9 +4,7 @@ from urllib.parse import parse_qs, urlsplit -from django.contrib.sessions.backends.db import SessionStore from django.core.exceptions import DisallowedRedirect -from django.test import RequestFactory import pytest @@ -17,18 +15,6 @@ pytestmark = [pytest.mark.django_db] -@pytest.fixture -def auth_request(rf: RequestFactory): - request = rf.get("/some-auth", {"next": "/ignored"}) - session = SessionStore() - session.save() - request.session = session - return request - - -# Use a proxy model to modify behaviour without needing migrations/models machinery. - - class static_setting: def __init__(self, val): self.val = val @@ -41,6 +27,7 @@ def __set__(self, obj, val): class CustomConfig(OpenIDConnectConfig): + # Use a proxy model to modify behaviour without needing migrations/models machinery. class Meta: proxy = True app_label = "mozilla_django_oidc_db"