diff --git a/CHANGELOG.rst b/CHANGELOG.rst
index 2acb624b..b6dd55de 100644
--- a/CHANGELOG.rst
+++ b/CHANGELOG.rst
@@ -33,7 +33,32 @@ Unreleased
----------
.. scriv-insert-here
-.. _changelog-3.7.0:
+.. _changelog-3.9.0:
+
+[3.9.0] - 2026-08-04
+--------------------
+
+Added
+~~~~~
+
+* Added new ``authentication`` architecture subdomain
+
+* Added six new authentication-related filters:
+
+ * ``LogistrationViewContextGenerated``
+ * ``AuthnMFEContextGenerated``
+ * ``LoginAltRedirectURLRequested``
+ * ``LoginFormGenerated``
+ * ``RegistrationFormGenerated``
+ * ``LogistrationViewRenderCompleted``
+
+* Added three new structural types used by several of the above filters:
+
+ * ``FormDescriptionProtocol`` - declares the minimal surface of the form
+ description that the form filters pass to pipeline steps.
+ * ``ProviderConfigProtocol`` - declares the minimal surface of the third-party
+ auth provider configuration that the form filters pass to pipeline steps.
+ * ``RunningPipeline`` - authentication pipeline state.
[3.8.0] - 2026-07-07
---------------------
diff --git a/MANIFEST.in b/MANIFEST.in
index 6e9e1200..1bbc4b2d 100644
--- a/MANIFEST.in
+++ b/MANIFEST.in
@@ -3,4 +3,5 @@ include LICENSE.txt
include README.rst
include requirements/base.in
recursive-include openedx_filters *.html *.png *.gif *.js *.css *.jpg *.jpeg *.svg *.py
+include openedx_filters/py.typed
include requirements/constraints.txt
diff --git a/docs/conf.py b/docs/conf.py
index 52911979..9a6a12dd 100644
--- a/docs/conf.py
+++ b/docs/conf.py
@@ -37,6 +37,7 @@
'sphinx_copybutton',
'sphinx.ext.graphviz',
'sphinxcontrib.mermaid',
+ 'myst_parser',
'sphinx.ext.autodoc',
'sphinx.ext.autosummary',
'sphinx.ext.intersphinx',
@@ -44,6 +45,11 @@
'sphinx.ext.linkcode',
]
+# Render fenced ```mermaid code blocks in Markdown (MyST) sources through the
+# sphinxcontrib.mermaid directive, so the same fences render both on GitHub and
+# in the Sphinx-built docs.
+myst_fence_as_directive = ["mermaid"]
+
# Add any paths that contain templates here, relative to this directory.
templates_path = ['_templates']
diff --git a/docs/decisions/0008-authentication-subdomain.md b/docs/decisions/0008-authentication-subdomain.md
new file mode 100644
index 00000000..758ed973
--- /dev/null
+++ b/docs/decisions/0008-authentication-subdomain.md
@@ -0,0 +1,100 @@
+# 8. The authentication architecture subdomain
+
+## Status
+
+Accepted
+
+## Context
+
+Filters are grouped by architecture subdomain (see
+[ADR-4](0004-filters-naming-and-versioning.rst)).
+Until now, the filters covering how users sign in and register —
+`StudentLoginRequested` and `StudentRegistrationRequested` — have lived in the
+`learning` subdomain, because that was the only user-facing subdomain available
+at the time.
+
+A new set of filters covering the login and registration *user experience* is
+being introduced: hooks around the login/registration page context and render
+lifecycle, the login and registration form descriptions, the authentication MFE
+(`frontend-app-authn`) context, and the post-login redirect. These needed a
+home, and `learning` is a poor fit: authentication gates access for *every* user
+of the platform — learners and content authors alike — so it is a distinct
+bounded context rather than a learning activity.
+
+## Decision
+
+We will introduce a new "Authentication" architecture subdomain, implemented in
+`openedx_filters/authentication/` with filter types under
+`org.openedx.authentication.*`. From now onward, new authentication-related
+filters should be added here.
+
+Docs: This new subdomain will be added to the [Architecture Subdomains Reference](../reference/architecture-subdomains.rst)
+alongside "Learning" and "Content Authoring".
+
+The following filters are added to it:
+
+- `LogistrationViewContextGenerated` — enrich the legacy (server-rendered) login/registration page context.
+- `LogistrationViewRenderCompleted` — modify the rendered legacy login/registration page response.
+- `AuthnMFEContextGenerated` — enrich the authentication MFE context.
+- `LoginFormGenerated` — augment the generated login form description.
+- `RegistrationFormGenerated` — augment the generated registration form description.
+- `LoginAltRedirectURLRequested` — choose an alternative post-login redirect.
+
+The diagram below shows when each authn-related filter fires within the authn flow.
+
+```mermaid
+flowchart TD
+ classDef filter fill:#e8f0fe,stroke:#1a73e8,color:#0a3069
+
+ ROUTES["/login and /register routes"]
+ LEGACY["Legacy logistration view
(server-rendered)"]
+ MFE["Authn MFE
(frontend-app-authn)"]
+
+ LogistrationViewContextGenerated["LogistrationViewContextGenerated
enrich legacy page context"]:::filter
+ LogistrationViewRenderCompleted["LogistrationViewRenderCompleted
post-render response hook,
useful for setting extra cookies"]:::filter
+ AuthnMFEContextGenerated["AuthnMFEContextGenerated
enrich MFE context"]:::filter
+ LoginFormGenerated["LoginFormGenerated
augment login form fields"]:::filter
+ RegistrationFormGenerated["RegistrationFormGenerated
augment registration form fields"]:::filter
+ StudentRegistrationRequested["StudentRegistrationRequested
hook for blocking registration"]:::filter
+ StudentLoginRequested["StudentLoginRequested
hook for blocking login"]:::filter
+ LoginAltRedirectURLRequested["LoginAltRedirectURLRequested
force alternative post-login redirect"]:::filter
+
+ RENDERED["Legacy page rendered"]
+ FORMS["Login/registration
FormDescription generation"]
+ LOGINPOST["Login POST endpoint"]
+ REGPOST["Registration POST endpoint"]
+ DEST["Post-auth destination,
URL possibly overridden by filter"]
+
+ ROUTES -- "AuthN MFE disabled for flow" --> LEGACY
+ ROUTES -- "AuthN MFE enabled for flow" --> MFE
+ LEGACY --> LogistrationViewContextGenerated --> RENDERED --> LogistrationViewRenderCompleted
+ LogistrationViewRenderCompleted -- "Get FormDescription via python API" --> FORMS
+ MFE --> AuthnMFEContextGenerated
+ AuthnMFEContextGenerated -- "Get FormDescription via REST API" --> FORMS
+ %% invisible edge: pin AuthnMFEContextGenerated to the same rank as
+ %% LogistrationViewContextGenerated so both *ContextGenerated filters render on the same level
+ AuthnMFEContextGenerated ~~~ RENDERED
+ FORMS -- "is /login route" --> LoginFormGenerated
+ FORMS -- "is /register route" --> RegistrationFormGenerated
+ %% invisible edge: pin LoginFormGenerated to the same rank as
+ %% RegistrationFormGenerated so both *FormGenerated filters render on the same level
+ LoginFormGenerated ~~~ REGPOST
+ LoginFormGenerated -- "submit login" --> LOGINPOST
+ RegistrationFormGenerated -- "submit registration" --> REGPOST
+ REGPOST --> StudentRegistrationRequested
+ LOGINPOST --> StudentLoginRequested
+ StudentLoginRequested -- "AuthN MFE enabled and the request is for first-party auth" --> LoginAltRedirectURLRequested
+ StudentLoginRequested -- "otherwise" --> DEST
+ LoginAltRedirectURLRequested --> DEST
+ StudentRegistrationRequested -- "account created and logged in" --> DEST
+```
+
+## Consequences
+
+- Future authentication-related filters have a clear, accurately named home and
+ no longer need to borrow the "Learning" subdomain.
+- The pre-existing authn filters `StudentLoginRequested` and
+ `StudentRegistrationRequested` filters **remain in the `learning` subdomain**.
+ They are released, versioned public contracts, and re-homing them would be a
+ breaking change for existing consumers. A future major version could migrate
+ the two longstanding filters.
diff --git a/docs/decisions/index.rst b/docs/decisions/index.rst
index 4f59ce25..86543390 100644
--- a/docs/decisions/index.rst
+++ b/docs/decisions/index.rst
@@ -14,3 +14,4 @@ Decisions
0005-filters-payload
0006-filter-debug-tooling
0007-filter-design-practices
+ 0008-authentication-subdomain
diff --git a/docs/reference/architecture-subdomains.rst b/docs/reference/architecture-subdomains.rst
index e216e9f9..431247c7 100644
--- a/docs/reference/architecture-subdomains.rst
+++ b/docs/reference/architecture-subdomains.rst
@@ -8,6 +8,8 @@ Currently, these are the `architecture subdomains`_ used by the Open edX Filters
+-------------------+----------------------------------------------------------------------------------------------------+
| Subdomain Name | Description |
+===================+====================================================================================================+
+| Authentication | Handles how users sign in, register, and are routed through login/registration flows. |
++-------------------+----------------------------------------------------------------------------------------------------+
| Content Authoring | Allows educators to create, modify, package, annotate (tag), and share learning content. |
+-------------------+----------------------------------------------------------------------------------------------------+
| Learning | Allows learners to consume content and perform actions in a learning activity on the platform. |
diff --git a/docs/reference/filters.rst b/docs/reference/filters.rst
index 8b83ce14..7f0be90e 100644
--- a/docs/reference/filters.rst
+++ b/docs/reference/filters.rst
@@ -20,6 +20,18 @@ Course Authoring Subdomain
.. automodule:: openedx_filters.course_authoring.filters
:members:
+Authentication Subdomain
+*************************
+
+.. automodule:: openedx_filters.authentication.filters
+ :members:
+
+Some of these filters hand their pipeline steps payloads whose shape is declared as a
+structural type, so that steps do not couple to a concrete platform implementation:
+
+.. automodule:: openedx_filters.authentication.types
+ :members:
+
**Maintenance chart**
+--------------+-------------------------------+----------------+--------------------------------+
diff --git a/openedx_filters/__init__.py b/openedx_filters/__init__.py
index 0cd28ccf..9a58d1c6 100644
--- a/openedx_filters/__init__.py
+++ b/openedx_filters/__init__.py
@@ -6,7 +6,7 @@
from openedx_filters.filters import *
-__version__ = "3.8.0"
+__version__ = "3.9.0"
if sys.version_info < (3, 12): # pragma: no cover
warnings.warn(
diff --git a/openedx_filters/authentication/__init__.py b/openedx_filters/authentication/__init__.py
new file mode 100644
index 00000000..c812d895
--- /dev/null
+++ b/openedx_filters/authentication/__init__.py
@@ -0,0 +1,6 @@
+"""
+Filters related to the authentication subdomain.
+
+The authentication subdomain covers how users sign in, register, and are routed through
+login and registration flows.
+"""
diff --git a/openedx_filters/authentication/filters.py b/openedx_filters/authentication/filters.py
new file mode 100644
index 00000000..2806b292
--- /dev/null
+++ b/openedx_filters/authentication/filters.py
@@ -0,0 +1,291 @@
+"""
+Package where filters related to the authentication architectural subdomain are implemented.
+"""
+
+from typing import Any
+
+from openedx_filters.authentication.types import FormDescriptionProtocol, ProviderConfigProtocol, RunningPipeline
+from openedx_filters.tooling import OpenEdxPublicFilter
+
+
+class LogistrationViewContextGenerated(OpenEdxPublicFilter):
+ """
+ Filter used to enrich or modify the combined login-and-registration page context.
+
+ Purpose:
+ This filter hooks into the legacy (server-rendered) login/registration flow. It is
+ triggered just after the combined login/registration page context has been generated
+ and just before the page is rendered, allowing pipeline steps to modify the context
+ dict (e.g. alter sidebar content) based on external conditions.
+
+ Filter Type:
+ org.openedx.authentication.logistration_view.context.generated.v1
+
+ Trigger:
+ - Repository: openedx/edx-platform
+ - Path: openedx/core/djangoapps/user_authn/views/login_form.py
+ - Function or Method: login_and_registration_form
+ """
+
+ filter_type = "org.openedx.authentication.logistration_view.context.generated.v1"
+
+ @classmethod
+ def run_filter(cls, context: dict) -> dict:
+ """
+ Process the context through the configured pipeline steps.
+
+ Arguments:
+ context (dict): the template context dict for the login/registration page.
+
+ Returns:
+ dict: the (possibly modified) context.
+ """
+ data = super().run_pipeline(context=context)
+ return data["context"]
+
+
+class AuthnMFEContextGenerated(OpenEdxPublicFilter):
+ """
+ Filter used to enrich or modify the authentication MFE context.
+
+ Purpose:
+ This filter hooks into the modern authentication MFE (frontend-app-authn) flow. It is
+ triggered just after the context served to the authentication micro-frontend has been
+ generated, allowing pipeline steps to add or modify entries served to the MFE (e.g.
+ branding data) based on external conditions. It is the MFE counterpart to
+ LogistrationViewContextGenerated, which enriches the legacy server-rendered page's
+ nested context.
+
+ The context is split across two arguments because the caller may only know how to
+ serve the entries it declares itself: pipeline steps modify entries the caller
+ already declares through ``context``, and contribute entries the caller does not
+ declare through ``extra_context``.
+
+ Filter Type:
+ org.openedx.authentication.mfe.context.generated.v1
+
+ Trigger:
+ - Repository: openedx/edx-platform
+ - Path: openedx/core/djangoapps/user_authn/views/utils.py
+ - Function or Method: get_mfe_context
+ """
+
+ filter_type = "org.openedx.authentication.mfe.context.generated.v1"
+
+ @classmethod
+ def run_filter(cls, context: dict, extra_context: dict) -> tuple[dict, dict]:
+ """
+ Process the context through the configured pipeline steps.
+
+ Arguments:
+ context (dict): the context dict served to the authentication MFE. Pipeline
+ steps modify the entries the caller declares itself.
+ extra_context (dict): additional entries to serve to the authentication MFE.
+ Pipeline steps add entries the caller does not declare itself. The caller
+ decides how these are merged into what it serves.
+
+ Returns:
+ tuple[dict, dict]:
+ dict: the (possibly modified) context.
+ dict: the (possibly populated) extra context.
+ """
+ data = super().run_pipeline(context=context, extra_context=extra_context)
+ return (data["context"], data["extra_context"])
+
+
+class LoginAltRedirectURLRequested(OpenEdxPublicFilter):
+ """
+ Filter used to determine an alternative redirect URL after a successful login.
+
+ Purpose:
+ This filter is triggered after a user has been authenticated, before the final redirect
+ is issued. Any pipeline step may return an alternative redirect URL to send the user
+ through additional post-login flows (e.g. an account-selection page).
+
+ Filter Type:
+ org.openedx.authentication.login.alt_redirect_url.requested.v1
+
+ Trigger:
+ - Repository: openedx/edx-platform
+ - Path: openedx/core/djangoapps/user_authn/views/login.py
+ - Function or Method: login_user
+ """
+
+ filter_type = "org.openedx.authentication.login.alt_redirect_url.requested.v1"
+
+ @classmethod
+ def run_filter(cls, redirect_url: str, user: Any) -> tuple[str, Any]:
+ """
+ Process the redirect URL through the configured pipeline steps.
+
+ Arguments:
+ redirect_url (str): the destination the caller intends to send the user to. A
+ pipeline step that redirects elsewhere may attempt to preserve this URL by
+ nesting it within another ``/?next=`` layer to create a chain of URLs.
+ user (User): the authenticated Django user.
+
+ Returns:
+ tuple[str, User]: the (possibly modified) redirect URL and the user.
+ """
+ data = super().run_pipeline(redirect_url=redirect_url, user=user)
+ return data["redirect_url"], data["user"]
+
+
+class LoginFormGenerated(OpenEdxPublicFilter):
+ """
+ Filter used to modify the login form description after it has been generated.
+
+ Purpose:
+ This filter is triggered for every login form build, before the form fields are
+ added. Pipeline steps may override field properties (e.g. defaults, visibility,
+ restrictions). Field property overrides take effect when the fields are subsequently
+ added, so steps run before field construction.
+
+ Pipeline steps can pass field overrides (enabling dynamic field hiding), but
+ cannot add fields of their own. At the time of this writing, there is no supported
+ mechanism for adding custom login fields. The registration form, however, does
+ support custom fields via the ``PROFILE_EXTENSION_FORM`` setting (or the
+ deprecated ``REGISTRATION_EXTENSION_FORM``) in platform.
+
+ The third-party auth state of the request is passed alongside the form description so
+ that pipeline steps can tailor the form to the provider the user is authenticating
+ with, without having to resolve that state themselves.
+
+ Filter Type:
+ org.openedx.authentication.login.form.generated.v1
+
+ Trigger:
+ - Repository: openedx/edx-platform
+ - Path: openedx/core/djangoapps/user_authn/views/login_form.py
+ - Function or Method: get_login_session_form
+ """
+
+ filter_type = "org.openedx.authentication.login.form.generated.v1"
+
+ @classmethod
+ def run_filter(
+ cls,
+ form_desc: FormDescriptionProtocol,
+ running_pipeline: RunningPipeline | None,
+ current_provider: ProviderConfigProtocol | None,
+ ) -> tuple[FormDescriptionProtocol, RunningPipeline | None, ProviderConfigProtocol | None]:
+ """
+ Process the login form description through the configured pipeline steps.
+
+ Arguments:
+ form_desc (FormDescriptionProtocol): the login form description.
+ running_pipeline (RunningPipeline): the third-party auth pipeline running for the
+ request, or None when third-party auth is disabled or no pipeline is running.
+ current_provider (ProviderConfigProtocol): the provider associated with the running
+ pipeline, or None when there is no running pipeline or the provider could not
+ be determined.
+
+ Returns:
+ tuple[FormDescriptionProtocol, RunningPipeline | None, ProviderConfigProtocol | None]:
+ the (possibly modified) form description, the running pipeline, and the current
+ provider.
+ """
+ data = super().run_pipeline(
+ form_desc=form_desc,
+ running_pipeline=running_pipeline,
+ current_provider=current_provider,
+ )
+ return data["form_desc"], data["running_pipeline"], data["current_provider"]
+
+
+class RegistrationFormGenerated(OpenEdxPublicFilter):
+ """
+ Filter used to modify the registration form description after it has been generated.
+
+ Purpose:
+ This filter is triggered for every registration form build, before the form fields
+ are added. Pipeline steps may override field properties (e.g. defaults, visibility,
+ restrictions). Field property overrides take effect when the fields are subsequently
+ added, so steps run before field construction.
+
+ Pipeline steps can pass field overrides (enabling dynamic field hiding), but
+ cannot add fields of their own. If you need to add fields instead, use the
+ ``PROFILE_EXTENSION_FORM`` setting (or the deprecated
+ ``REGISTRATION_EXTENSION_FORM``) in platform.
+
+ The third-party auth state of the request is passed alongside the form description so
+ that pipeline steps can tailor the form to the provider the user is registering
+ through, without having to resolve that state themselves.
+
+ Filter Type:
+ org.openedx.authentication.registration.form.generated.v1
+
+ Trigger:
+ - Repository: openedx/edx-platform
+ - Path: openedx/core/djangoapps/user_authn/views/registration_form.py
+ - Function or Method: RegistrationFormFactory.get_registration_form
+ """
+
+ filter_type = "org.openedx.authentication.registration.form.generated.v1"
+
+ @classmethod
+ def run_filter(
+ cls,
+ form_desc: FormDescriptionProtocol,
+ running_pipeline: RunningPipeline | None,
+ current_provider: ProviderConfigProtocol | None,
+ ) -> tuple[FormDescriptionProtocol, RunningPipeline | None, ProviderConfigProtocol | None]:
+ """
+ Process the registration form description through the configured pipeline steps.
+
+ Arguments:
+ form_desc (FormDescriptionProtocol): the registration form description.
+ running_pipeline (RunningPipeline): the third-party auth pipeline running for the
+ request, or None when third-party auth is disabled or no pipeline is running.
+ current_provider (ProviderConfigProtocol): the provider associated with the running
+ pipeline, or None when there is no running pipeline or the provider could not
+ be determined.
+
+ Returns:
+ tuple[FormDescriptionProtocol, RunningPipeline | None, ProviderConfigProtocol | None]:
+ the (possibly modified) form description, the running pipeline, and the current
+ provider.
+ """
+ data = super().run_pipeline(
+ form_desc=form_desc,
+ running_pipeline=running_pipeline,
+ current_provider=current_provider,
+ )
+ return data["form_desc"], data["running_pipeline"], data["current_provider"]
+
+
+class LogistrationViewRenderCompleted(OpenEdxPublicFilter):
+ """
+ Filter used to modify the rendered login/registration page response.
+
+ Purpose:
+ This filter hooks into the legacy (server-rendered) login/registration flow. It is
+ triggered right after the combined login/registration page has been rendered, allowing
+ pipeline steps to modify the response (e.g. set or delete cookies, add headers) using
+ the final page context.
+
+ Filter Type:
+ org.openedx.authentication.logistration_view.render.completed.v1
+
+ Trigger:
+ - Repository: openedx/edx-platform
+ - Path: openedx/core/djangoapps/user_authn/views/login_form.py
+ - Function or Method: login_and_registration_form
+ """
+
+ filter_type = "org.openedx.authentication.logistration_view.render.completed.v1"
+
+ @classmethod
+ def run_filter(cls, response: Any, context: dict) -> tuple[Any, dict]:
+ """
+ Process the response and context through the configured pipeline steps.
+
+ Arguments:
+ response (HttpResponse): the rendered login/registration page response.
+ context (dict): the template context dict used to render the page.
+
+ Returns:
+ tuple[HttpResponse, dict]: the (possibly modified) response and the context.
+ """
+ data = super().run_pipeline(response=response, context=context)
+ return data["response"], data["context"]
diff --git a/openedx_filters/authentication/tests/__init__.py b/openedx_filters/authentication/tests/__init__.py
new file mode 100644
index 00000000..1b052bc2
--- /dev/null
+++ b/openedx_filters/authentication/tests/__init__.py
@@ -0,0 +1,3 @@
+"""
+Unit tests for authentication subdomain filters.
+"""
diff --git a/openedx_filters/authentication/tests/test_filters.py b/openedx_filters/authentication/tests/test_filters.py
new file mode 100644
index 00000000..a0f9df2b
--- /dev/null
+++ b/openedx_filters/authentication/tests/test_filters.py
@@ -0,0 +1,162 @@
+"""
+Tests for authentication subdomain filters.
+"""
+from unittest.mock import Mock
+
+from django.test import TestCase
+
+from openedx_filters.authentication.filters import (
+ AuthnMFEContextGenerated,
+ LoginAltRedirectURLRequested,
+ LoginFormGenerated,
+ LogistrationViewContextGenerated,
+ LogistrationViewRenderCompleted,
+ RegistrationFormGenerated,
+)
+from openedx_filters.authentication.types import RunningPipeline
+
+
+class TestLogistrationViewContextGeneratedFilter(TestCase):
+ """
+ Tests for the LogistrationViewContextGenerated filter.
+ """
+
+ def test_filter_type(self):
+ assert LogistrationViewContextGenerated.filter_type == \
+ "org.openedx.authentication.logistration_view.context.generated.v1"
+
+ def test_run_filter_passes_through_context(self):
+ context = {"data": {}}
+
+ returned_context = LogistrationViewContextGenerated.run_filter(context)
+
+ assert returned_context is context
+
+
+class TestAuthnMFEContextGeneratedFilter(TestCase):
+ """
+ Tests for the AuthnMFEContextGenerated filter.
+ """
+
+ def test_filter_type(self):
+ assert AuthnMFEContextGenerated.filter_type == "org.openedx.authentication.mfe.context.generated.v1"
+
+ def test_run_filter_passes_through_all_arguments(self):
+ context = {"countryCode": "US"}
+ extra_context = {}
+
+ returned_context, returned_extra_context = AuthnMFEContextGenerated.run_filter(context, extra_context)
+
+ assert returned_context is context
+ assert returned_extra_context is extra_context
+
+
+class TestLoginAltRedirectURLRequestedFilter(TestCase):
+ """
+ Tests for the LoginAltRedirectURLRequested filter.
+ """
+
+ def test_filter_type(self):
+ assert LoginAltRedirectURLRequested.filter_type == \
+ "org.openedx.authentication.login.alt_redirect_url.requested.v1"
+
+ def test_run_filter_passes_through_all_arguments(self):
+ user = Mock()
+
+ returned_url, returned_user = LoginAltRedirectURLRequested.run_filter("/dashboard", user)
+
+ assert returned_url == "/dashboard"
+ assert returned_user is user
+
+
+class TestLoginFormGeneratedFilter(TestCase):
+ """
+ Tests for the LoginFormGenerated filter.
+ """
+
+ def test_filter_type(self):
+ assert LoginFormGenerated.filter_type == \
+ "org.openedx.authentication.login.form.generated.v1"
+
+ def test_run_filter_passes_through_all_arguments(self) -> None:
+ form_desc = Mock()
+ running_pipeline: RunningPipeline = {
+ "kwargs": {"details": {}, "response": {}},
+ "backend": "tpa-saml",
+ }
+ current_provider = Mock()
+
+ returned_form_desc, returned_pipeline, returned_provider = LoginFormGenerated.run_filter(
+ form_desc, running_pipeline, current_provider,
+ )
+
+ assert returned_form_desc is form_desc
+ assert returned_pipeline is running_pipeline
+ assert returned_provider is current_provider
+
+ def test_run_filter_passes_through_absent_third_party_auth_state(self):
+ form_desc = Mock()
+
+ returned_form_desc, returned_pipeline, returned_provider = LoginFormGenerated.run_filter(
+ form_desc, None, None,
+ )
+
+ assert returned_form_desc is form_desc
+ assert returned_pipeline is None
+ assert returned_provider is None
+
+
+class TestRegistrationFormGeneratedFilter(TestCase):
+ """
+ Tests for the RegistrationFormGenerated filter.
+ """
+
+ def test_filter_type(self):
+ assert RegistrationFormGenerated.filter_type == \
+ "org.openedx.authentication.registration.form.generated.v1"
+
+ def test_run_filter_passes_through_all_arguments(self) -> None:
+ form_desc = Mock()
+ running_pipeline: RunningPipeline = {
+ "kwargs": {"details": {}, "response": {}},
+ "backend": "tpa-saml",
+ }
+ current_provider = Mock()
+
+ returned_form_desc, returned_pipeline, returned_provider = RegistrationFormGenerated.run_filter(
+ form_desc, running_pipeline, current_provider,
+ )
+
+ assert returned_form_desc is form_desc
+ assert returned_pipeline is running_pipeline
+ assert returned_provider is current_provider
+
+ def test_run_filter_passes_through_absent_third_party_auth_state(self):
+ form_desc = Mock()
+
+ returned_form_desc, returned_pipeline, returned_provider = RegistrationFormGenerated.run_filter(
+ form_desc, None, None,
+ )
+
+ assert returned_form_desc is form_desc
+ assert returned_pipeline is None
+ assert returned_provider is None
+
+
+class TestLogistrationViewRenderCompletedFilter(TestCase):
+ """
+ Tests for the LogistrationViewRenderCompleted filter.
+ """
+
+ def test_filter_type(self):
+ assert LogistrationViewRenderCompleted.filter_type == \
+ "org.openedx.authentication.logistration_view.render.completed.v1"
+
+ def test_run_filter_passes_through_all_arguments(self):
+ response = Mock()
+ context = {"enable_sidebar": False}
+
+ returned_response, returned_context = LogistrationViewRenderCompleted.run_filter(response, context)
+
+ assert returned_response is response
+ assert returned_context is context
diff --git a/openedx_filters/authentication/types.py b/openedx_filters/authentication/types.py
new file mode 100644
index 00000000..29905f2b
--- /dev/null
+++ b/openedx_filters/authentication/types.py
@@ -0,0 +1,99 @@
+"""
+Structural types shared by filters in the authentication subdomain.
+
+These declare the shape of the payloads that filters in this subdomain pass to their pipeline
+steps, so that both the caller producing them and the pipeline step consuming them can be
+checked against a single shared definition:
+
+* the ``TypedDict`` declarations describe the mappings the filters pass along, and
+* the structural (PEP 544) ``Protocol`` declarations describe the minimal surface of the
+ objects the filters pass along, without coupling the filters to any concrete platform
+ implementation.
+"""
+
+from typing import Any, Protocol, Required, TypedDict
+
+
+class RunningPipelineKwargs(TypedDict, total=False):
+ """
+ Partial shape of a paused authentication pipeline's accumulated keyword arguments.
+
+ ``details`` and ``response`` are the only two keys that may be accessed
+ unconditionally; the rest should be read with ``.get()``, because their values depend
+ on how far the pipeline had progressed
+ """
+
+ details: Required[dict[str, Any]]
+ response: Required[dict[str, Any]]
+ username: str | None
+ uid: str | None
+ is_new: bool
+ new_association: bool
+ auth_entry: str
+ user: Any
+ social: Any
+
+
+class RunningPipeline(TypedDict):
+ """
+ Shape of the authentication pipeline state for a request.
+
+ This is the payload that the login and registration form filters pass to their pipeline
+ steps to describe the authentication attempt in flight, so that steps can tailor the form
+ to the provider the user is authenticating with.
+
+ Unlike its ``kwargs`` member, this mapping is built by its caller as a complete literal
+ with exactly these two keys, so it is declared with both of them required: adding,
+ omitting or misspelling a key is an error on the caller's side.
+
+ ``backend`` names the authentication backend driving the attempt, and ``kwargs`` holds the
+ keyword arguments the pipeline has accumulated so far.
+ """
+
+ kwargs: RunningPipelineKwargs
+ backend: str
+
+
+class FormDescriptionProtocol(Protocol):
+ """
+ Structural interface of the FormDescription object passed to the form-override filters.
+
+ Only the minimal surface consumed by pipeline steps is declared here. Pipeline steps
+ should not rely on anything beyond this protocol.
+
+ This protocol is deliberately limited to overriding the properties of fields the
+ caller already defines. Adding new registration fields is instead the job of the
+ ``PROFILE_EXTENSION_FORM`` setting (or the deprecated ``REGISTRATION_EXTENSION_FORM``)
+ in platform settings, which covers ingestion, persistence, and ordering. Custom login
+ fields, however, cannot be added at the time of this writing.
+ """
+
+ def override_field_properties(
+ self,
+ field_name: str,
+ /,
+ *,
+ default: Any = ...,
+ field_type: str = ...,
+ label: str = ...,
+ instructions: str = ...,
+ restrictions: dict = ...,
+ ) -> None:
+ """Override the given properties of the named form field."""
+ ... # pylint: disable=unnecessary-ellipsis
+
+
+class ProviderConfigProtocol(Protocol):
+ """
+ Structural interface of the third-party auth provider configuration.
+
+ Only the minimal surface consumed by pipeline steps is declared here. Pipeline steps
+ should not rely on anything beyond this protocol.
+ """
+
+ provider_id: str
+ skip_registration_form: bool
+
+ def get_register_form_data(self, pipeline_kwargs: RunningPipelineKwargs, /) -> dict:
+ """Return the registration form field values prefilled by the provider."""
+ ... # pylint: disable=unnecessary-ellipsis
diff --git a/openedx_filters/filters.py b/openedx_filters/filters.py
index 3a38c586..54e1e3b2 100644
--- a/openedx_filters/filters.py
+++ b/openedx_filters/filters.py
@@ -3,6 +3,7 @@
"""
from abc import abstractmethod
from logging import getLogger
+from typing import Any
log = getLogger(__name__)
@@ -54,13 +55,21 @@ def __init__(self, filter_type, running_pipeline, **extra_config):
self.extra_config = extra_config
@abstractmethod
- def run_filter(self, **kwargs):
+ # The explicit ``return None`` below is required by static type checkers, which treat an
+ # implicit fall-through as a missing return whenever a return type is annotated.
+ def run_filter( # pylint: disable=useless-return
+ self, *args: Any, **kwargs: Any,
+ ) -> dict[str, Any] | None:
"""
Abstract pipeline step runner.
Used to implement custom code that'll be executed by OpenEdxPublicFilter's pipeline runner.
It must be implemented by child classes.
+ The signature is intentionally declared in its most permissive (gradual) form so that
+ subclasses may narrow the accepted keyword arguments to those of the filter they
+ implement without static type checkers reporting an incompatible override.
+
By design, the pipeline expects either of three (3) types of returns:
1. A dictionary with the arguments the method received. They can be modified in the process.
@@ -81,3 +90,4 @@ def run_filter(self, **kwargs):
"3. An object different from a dict. Returning this will stop the pipeline execution. "
"The accumulated output until this moment will be returned.\n"
)
+ return None
diff --git a/openedx_filters/py.typed b/openedx_filters/py.typed
new file mode 100644
index 00000000..e69de29b
diff --git a/openedx_filters/tests/test_tooling.py b/openedx_filters/tests/test_tooling.py
index 3cffbad2..df591f3f 100644
--- a/openedx_filters/tests/test_tooling.py
+++ b/openedx_filters/tests/test_tooling.py
@@ -33,7 +33,7 @@ class FirstPipelineStep(PipelineStep):
Utility function used when getting steps for pipeline.
"""
- def run_filter(self, **kwargs):
+ def run_filter(self, **kwargs): # pylint: disable=arguments-differ
return {}
@@ -42,7 +42,7 @@ class SecondPipelineStep(PipelineStep):
Utility class used when getting steps for pipeline.
"""
- def run_filter(self, **kwargs):
+ def run_filter(self, **kwargs): # pylint: disable=arguments-differ
return {}
diff --git a/requirements/doc.in b/requirements/doc.in
index 19c4c84f..d6c6e884 100644
--- a/requirements/doc.in
+++ b/requirements/doc.in
@@ -11,4 +11,5 @@ sphinx-book-theme
sphinx-copybutton
sphinx-autobuild
sphinxcontrib-mermaid
+myst-parser # Markdown (MyST) support for Sphinx docs
diff --git a/requirements/doc.txt b/requirements/doc.txt
index f97771fd..9d2ccd0e 100644
--- a/requirements/doc.txt
+++ b/requirements/doc.txt
@@ -115,11 +115,16 @@ librt==0.11.0
# -r requirements/test.txt
# mypy
markdown-it-py==4.2.0
- # via rich
+ # via
+ # mdit-py-plugins
+ # myst-parser
+ # rich
markupsafe==3.0.3
# via
# -r requirements/test.txt
# jinja2
+mdit-py-plugins==0.6.1
+ # via myst-parser
mdurl==0.1.2
# via markdown-it-py
more-itertools==11.0.2
@@ -132,6 +137,8 @@ mypy-extensions==1.1.0
# via
# -r requirements/test.txt
# mypy
+myst-parser==5.1.0
+ # via -r requirements/doc.in
nh3==0.3.5
# via readme-renderer
packaging==26.2
diff --git a/setup.py b/setup.py
index 68da97f2..f9ee1957 100644
--- a/setup.py
+++ b/setup.py
@@ -145,6 +145,7 @@ def is_requirement(line):
packages=[
"openedx_filters",
],
+ package_data={"openedx_filters": ["py.typed"]},
include_package_data=True,
install_requires=load_requirements("requirements/base.in"),
python_requires=">=3.11",