Skip to content

Commit

Permalink
Merge pull request #105 from maykinmedia/feature/99-parametrized-call…
Browse files Browse the repository at this point in the history
…back-view

Parametrize callback views
  • Loading branch information
sergei-maertens authored May 24, 2024
2 parents 72a13b2 + b844327 commit a7189eb
Show file tree
Hide file tree
Showing 12 changed files with 490 additions and 14 deletions.
93 changes: 93 additions & 0 deletions docs/architecture.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
============
Architecture
============

The architecture of mozilla-django-oidc-db is so that you can bring your own
configuration classes/models and adapt the behaviour, while still encapsulating the
OpenID Connect protocol interaction. See :ref:`customizing` for how to do this.

The flow is captured in the ASCII art below.

.. code-block:: none
+--------------+ +--------------+ +--------------+
| init@ConfigA | | init@ConfigB | | init@ConfigC |
+------+-------+ +-----+--------+ +------+-------+
| | |
+----------------+------------------+
|
v
+------+------+
| OIDCInit |
+------+------+
|
v
+---+---+
| OP |
+---+---+
|
v
+-----------+------------+
| Routing Callback View |
+-----------+------------+
|
v
+-----------+------------+
| |
v v
+-----+------+ +----+-------+
| Callback X | | Callback Y |
+-----+------+ +----+-------+
| |
+-----------+------------+
|
v
+-----+--------+
| Auth Backend |
+--------------+
|
v
+----------+-------------+
| Callback view redirect |
+------------------------+
The assumed configuration inheritance chain is:

.. code-block:: python
class ConfigA(OpenIDConnectConfigBase):
...
class ConfigB(OpenIDConnectConfigBase):
...
class ConfigC(OpenIDConnectConfigBase):
...
That is - each config class has its own behaviours associated.

Flow
====

In this diagram, there are three OIDC init entrypoints, one for each configuration model.
They share the initialization logic (:class:`~mozilla_django_oidc_db.views.OIDCInit`),
which takes care of recording the desired configuration class to apply in the callback
flow.

Then, the user is redirected to the OpenID Provider, where they authenticate with their
credentials. On successful auth (or error situations), the user is redirected to the
callback endpoint, ending up in our ``Routing Callback View``. This looks up which
callback view function to apply, depending on the configuration specified during the
init flow.

Typically, as part of the callback view, the ``settings.AUTHENTICATION_BACKENDS`` will
be tried in order, which will hit (one of) our backends that completes the OpenID
Connect flow, yielding user information. This would typically result in a django user
being logged in and being redirected to the success (or failure) URL specified from
the callback.

Note that not every configuration class needs to provide their own callback view.
44 changes: 41 additions & 3 deletions docs/customizing.rst
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
.. _customizing:

=====================
Customizing behaviour
=====================
Expand Down Expand Up @@ -137,7 +139,43 @@ Callback flow
=============

:class:`~mozilla_django_oidc_db.views.OIDCCallbackView` takes care of preparing the
request for the authentication backend(s). You can provide a different class to
implement your own error handling, for example.
request for the authentication backend(s). Then, it grabs the callback view to apply
from the selected config model (by default this is
:class:`~mozilla_django_oidc_db.views.OIDCAuthenticationCallbackView`, making the
settings dynamic).

You can provide your own callback view handler and override behaviour. We recommend
you use :class:`~mozilla_django_oidc_db.views.OIDCAuthenticationCallbackView` as a
base. You can override any of the methods in
:class:`mozilla_django_oidc.views.OIDCAuthenticationCallbackView` of the upstream
library.

Finally, you must point to this view by overriding the :meth:`~mozilla_django_oidc_db.models.OpenIDConnectConfigBase.get_callback_view`
model method.

For example:

.. code-block:: python
# views.py
from mozilla_django_oidc_db.views import OIDCAuthenticationCallbackView
class CustomCallbackView(OIDCAuthenticationCallbackView):
@property
def success_url(self):
return "/custom-success-url"
custom_callback_view = CustomCallbackView.as_view()
# models.py
class CustomCallbackViewConfig(OpenIDConnectConfigBase):
...
def get_callback_view(self):
from .views import custom_callback_view
.. todo:: refactor to (abstract) base and concrete class
return custom_callback_view
1 change: 1 addition & 0 deletions docs/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ Using ``email`` as the unique identifier is not recommended, as mentioned in the
quickstart
customizing
reference
architecture
changelog


Expand Down
6 changes: 6 additions & 0 deletions docs/reference.rst
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,12 @@ Authentication backends
.. automodule:: mozilla_django_oidc_db.backends
:members:

Models
======

.. automodule:: mozilla_django_oidc_db.models
:members:

Utils
=====

Expand Down
17 changes: 16 additions & 1 deletion mozilla_django_oidc_db/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
import mozilla_django_oidc_db.settings as oidc_settings

from .fields import ClaimField
from .typing import ClaimPath
from .typing import ClaimPath, DjangoView


class UserInformationClaimsSources(models.TextChoices):
Expand Down Expand Up @@ -276,6 +276,16 @@ def oidcdb_username_claim(self) -> ClaimPath:
def oidcdb_userinfo_claims_source(self) -> UserInformationClaimsSources:
return self.userinfo_claims_source

def get_callback_view(self) -> DjangoView:
"""
Determine the view callable to use for the callback flow.
The view will only be called with a request argument.
"""
from .views import default_callback_view

return default_callback_view


class OpenIDConnectConfig(CachingMixin, OpenIDConnectConfigBase):
"""
Expand Down Expand Up @@ -419,3 +429,8 @@ def oidcdb_make_users_staff(self) -> bool:
@property
def oidcdb_superuser_group_names(self) -> Collection[str]:
return self.superuser_group_names # type: ignore

def get_callback_view(self):
from .views import admin_callback_view

return admin_callback_view
8 changes: 7 additions & 1 deletion mozilla_django_oidc_db/typing.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,16 @@
from __future__ import annotations

from collections.abc import Sequence
from typing import TypeAlias
from typing import Protocol, TypeAlias

from django.http import HttpRequest, HttpResponseBase

JSONPrimitive: TypeAlias = str | int | float | bool | None
JSONValue: TypeAlias = "JSONPrimitive | list[JSONValue] | JSONObject"
JSONObject: TypeAlias = dict[str, JSONValue]

ClaimPath: TypeAlias = Sequence[str]


class DjangoView(Protocol):
def __call__(self, request: HttpRequest) -> HttpResponseBase: ...
65 changes: 57 additions & 8 deletions mozilla_django_oidc_db/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,18 +5,19 @@
from django.contrib import admin
from django.core.exceptions import DisallowedRedirect, PermissionDenied, ValidationError
from django.db import IntegrityError, transaction
from django.http import HttpRequest, HttpResponseRedirect
from django.http import HttpRequest, HttpResponseBase, HttpResponseRedirect
from django.urls import reverse_lazy
from django.utils.http import url_has_allowed_host_and_scheme
from django.views import View
from django.views.generic import TemplateView

import requests
from mozilla_django_oidc.views import (
OIDCAuthenticationCallbackView,
OIDCAuthenticationRequestView as _OIDCAuthenticationRequestView,
OIDCAuthenticationCallbackView as BaseOIDCCallbackView,
OIDCAuthenticationRequestView as BaseOIDCInitView,
)

from .config import get_setting_from_config, store_config
from .config import get_setting_from_config, lookup_config, store_config
from .exceptions import OIDCProviderOutage
from .models import OpenIDConnectConfig, OpenIDConnectConfigBase

Expand Down Expand Up @@ -52,16 +53,60 @@ def get_exception_message(exc: Exception) -> str:
return exc.args[0]


class OIDCCallbackView(OIDCAuthenticationCallbackView):
class OIDCCallbackView(View):
"""
Route to the appropriate callback request handler.
When a callback request is received, the state contains information about the
configuration class/model to be applied. A particular config_class may require
certain view behaviour. This view acts as a centralized entrypoint so that there
is only a single callback endpoint required. It ensures that the configuration
is extracted from the request.
"""

def get(self, request: HttpRequest) -> HttpResponseBase:
"""
Extract the state from the request parameters and persist it.
The state is extracted so that it's available for the authentication backend(s)
and the downstream views.
"""
store_config(request)
config_class = lookup_config(request)
config = cast(OpenIDConnectConfigBase, config_class.get_solo())
view_function = config.get_callback_view()
return view_function(request)


class OIDCAuthenticationCallbackView(BaseOIDCCallbackView):
"""
Base callback view that retrieves the settings from the config object.
"""

def get_settings(self, attr: str, *args: Any) -> 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:
config_class = lookup_config(self.request)
# django-solo and type checking is challenging, but a new release is on the
# way and should fix that :fingers_crossed:
config = cast(OpenIDConnectConfigBase, config_class.get_solo())
self._config = config
return get_setting_from_config(config, attr, *args)


class AdminCallbackView(OIDCAuthenticationCallbackView):
"""
Intercept errors raised by the authentication backend and display them.
"""

failure_url = reverse_lazy("admin-oidc-error")

def get(self, request: HttpRequest):
store_config(request)

try:
# ensure errors don't lead to half-created users
with transaction.atomic():
Expand All @@ -80,6 +125,10 @@ def get(self, request: HttpRequest):
return response


default_callback_view = OIDCAuthenticationCallbackView.as_view()
admin_callback_view = AdminCallbackView.as_view()


class AdminLoginFailure(TemplateView):
"""
Template view in admin style to display OIDC login errors
Expand All @@ -102,7 +151,7 @@ def get_context_data(self, **kwargs):
T = TypeVar("T", bound=OpenIDConnectConfigBase)


class OIDCInit(Generic[T], _OIDCAuthenticationRequestView):
class OIDCInit(Generic[T], BaseOIDCInitView):
"""
A 'view' to start an OIDC authentication flow.
Expand Down
Loading

0 comments on commit a7189eb

Please sign in to comment.