Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@ Unreleased

* nothing unreleased

[8.0.5] - 2026-05-04
---------------------
* feat: add TPA pipeline step and social auth disconnect handler

[8.0.4] - 2026-05-05
---------------------
* feat: extend manage enterprise customer admins permission to the enterprise_openedx_operator role
Expand Down
18 changes: 15 additions & 3 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -216,14 +216,26 @@ test-shell-logs: dev.logs
test-shell-restart-container: dev.restart-container
test-shell-attach: dev.attach

dev.up.keycloak:
dev.up.keycloak: ## start the Keycloak container for SAML IdP testing
docker compose up --detach keycloak

dev.stop.keycloak:
dev.stop.keycloak: ## stop the Keycloak container
docker compose stop keycloak

dev.provision.keycloak: ## provision Keycloak SAML IdP and LMS TPA records for testing
docker compose run --rm keycloak-config-cli
docker exec -i --env-file keycloak-devstack.env edx.devstack.lms \
python manage.py lms --settings devstack shell < provision-tpa.py
@echo ""
@echo "NOTE: Your browser machine needs this /etc/hosts entry for SAML login to work:"
@echo " 127.0.0.1 edx.devstack.keycloak"
@echo ""
@echo "If you are using a remote codespace, update /etc/hosts on your local machine"
@echo "(where VS Code / the browser runs), not inside the codespace."
@echo ""

.PHONY: clean clean.static compile_translations coverage docs dummy_translations extract_translations \
fake_translations help pull_translations push_translations requirements dev_requirements test upgrade validate isort \
isort-check static static.dev static.watch quality pylint pycodestyle pii_check pii_clean jasmine \
dev.pull dev.up dev.down dev.stop dev.makemigrations dev.shell dev.logs dev.restart-container dev.attach \
dev.up.keycloak dev.stop.keycloak
dev.up.keycloak dev.stop.keycloak dev.provision.keycloak
30 changes: 25 additions & 5 deletions docker-compose.yml
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# Docker in this repo is only supported for running tests locally
services:
# test-shell is only provided for running tests locally
test-shell:
build:
context: .
Expand All @@ -13,22 +13,42 @@ services:
environment:
DJANGO_SETTINGS_MODULE: enterprise.settings.test

# Run a IdP in devstack (powered by keycloak).
keycloak:
container_name: "edx.devstack.keycloak"
hostname: keycloak.devstack.edx
image: quay.io/keycloak/keycloak:22.0.1
image: quay.io/keycloak/keycloak:26.6
command: start-dev
networks:
default:
devstack_default:
aliases:
- edx.devstack.keycloak
ports:
- "8080:8080"
environment:
KEYCLOAK_ADMIN: admin
KEYCLOAK_ADMIN_PASSWORD: admin
KC_BOOTSTRAP_ADMIN_USERNAME: admin
KC_BOOTSTRAP_ADMIN_PASSWORD: admin
KC_HTTP_HOST: "0.0.0.0"
volumes:
- keycloak_data:/opt/keycloak/data

keycloak-config-cli:
image: adorsys/keycloak-config-cli:latest-26
env_file: keycloak-devstack.env
volumes:
- ./keycloak-devstack.properties:/opt/keycloak-config-cli.properties:ro
- ./keycloak-devstack-realm.json:/config/keycloak-devstack-realm.json:ro
command: --spring.config.additional-location=/opt/keycloak-config-cli.properties
networks:
- devstack_default
depends_on:
- keycloak
profiles:
- provision

networks:
devstack_default:
external: true

volumes:
keycloak_data:
1 change: 1 addition & 0 deletions docs/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ Contents:
configuration_and_usage
development
testing
saml_testing
internationalization
segment_events
modules
Expand Down
188 changes: 188 additions & 0 deletions docs/saml_testing.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
.. _saml-testing-section:

SAML Testing with Keycloak
==========================

edx-enterprise ships a local `Keycloak <https://www.keycloak.org/>`_ setup that
acts as a SAML Identity Provider (IdP) against a devstack LMS. This lets you
test the full SAML login flow -- including the enterprise TPA pipeline steps --
without an external IdP.

Prerequisites
-------------

* A running `devstack <https://github.com/openedx/devstack>`_ environment with
the LMS container up.
* The edx-enterprise branch you want to test installed as an editable package
inside the LMS container (``/edx/src/edx-enterprise``).
* Docker Compose (the ``docker compose`` CLI plugin).

Starting Keycloak
-----------------

From the **edx-enterprise** repository root:

.. code-block:: bash

$ make dev.up.keycloak

This starts a Keycloak 26.x container (``edx.devstack.keycloak``) on the
devstack Docker network, exposed at ``http://localhost:8080``. Keycloak data is
persisted in a Docker volume (``keycloak_data``) so the container can be stopped
and restarted without losing state.

Provisioning
------------

Provisioning configures **both** Keycloak and the LMS in a single step:

.. code-block:: bash

$ make dev.provision.keycloak

Under the hood this runs two commands:

1. ``keycloak-config-cli`` imports the realm definition
(``keycloak-devstack-realm.json``) into Keycloak, creating a ``devstack``
realm with a SAML client and a test user.
2. ``provision-tpa.py`` runs inside the LMS container to create the matching
``SAMLConfiguration``, ``SAMLProviderConfig``, ``EnterpriseCustomer`` link,
and a pre-linked LMS learner account.

All shared configuration values (URLs, entity IDs, OIDs, test credentials) live
in ``keycloak-devstack.env`` so the two sides stay in sync.

Host setup
----------

The LMS redirects to Keycloak using the Docker hostname
``edx.devstack.keycloak``. Your browser needs to resolve that name to
localhost.

Add this line to ``/etc/hosts`` on the machine where your browser runs (your
laptop, **not** a remote codespace):

.. code-block:: text

127.0.0.1 edx.devstack.keycloak

Testing the SAML login flow
----------------------------

1. Navigate to the SAML login URL:

``http://localhost:18000/auth/login/tpa-saml/?auth_entry=login&idp=keycloak-devstack``

2. You should be redirected to the Keycloak login page at
``http://edx.devstack.keycloak:8080/realms/devstack/...``.

3. Log in with the test credentials:

========= =========================
Username ``keycloak_learner``
Password ``testpass``
========= =========================

4. Validate that you were **not** prompted to log into the existing LMS user.
The ``enterprise_associate_by_email`` pipeline step should discover that the
pre-provisioned LMS learner is already associated with the SAML-enabled
enterprise customer, so LMS authentication is skipped.

5. Validate that you have been redirected to the LMS learner dashboard and are
logged in as ``keycloak_test_learner``.

Testing the SAML disconnect flow
--------------------------------

Triggering the disconnect via the Account MFE
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

1. Bring up the Account MFE container (devstack runs it on port 1997):

.. code-block:: bash

$ make dev.up.frontend-app-account

2. In the same browser session where you completed the SAML login, navigate to
the Linked Accounts section:

http://localhost:1997/#linked-accounts

3. Find the Keycloak Devstack IdP entry (matches
SAMLProviderConfig.name) and click **Unlink Keycloak Devstack IdP
account**.

4. The button should settle into the "unconnected" state with a "Sign in with
Keycloak Devstack IdP" link. indicating the MFE received a successful
disconnect response.

Verifying the disconnect
~~~~~~~~~~~~~~~~~~~~~~~~

1. **LMS logs** -- tail the LMS container and grep for the new debug lines:

.. code-block:: bash

$ docker logs --tail 500 edx.devstack.lms 2>&1 | grep -E 'SAMLAccountDisconnected|_unlink_enterprise_user_from_idp|successfully unlinked'

You should see all three lines, in order::

[THIRD_PARTY_AUTH] Emitting SAMLAccountDisconnected signal for user_id=<id>, backend=tpa-saml
[ENTERPRISE] _unlink_enterprise_user_from_idp called for user_id=<id>, backend=tpa-saml
Enterprise learner {keycloak_learner@example.com} successfully unlinked from Enterprise Customer {<name>}

Resetting state to repeat the test
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

The simplest reset is to re-run provisioning:

.. code-block:: bash

$ make dev.provision.keycloak

Then navigate to the SAML login URL again to re-link:

http://localhost:18000/auth/login/tpa-saml/?auth_entry=login&idp=keycloak-devstack

Note: re-running provisioning is necessary because when you clicked the
**Unlink Keycloak Devstack IdP account** button, the SAML disconnect handler
did more than just disconnect from the IdP, it also unlinked the
EnterpriseCustomerUser. This is only recoverable by an admin or system
operator, hence the need to use the provision script. Yes, that means in prod
if a learner accidentally clicks the unlink-from-IdP button, they ALSO get
unlinked from the enterprise itself and need to reach out to their admin to get
re-linked to the enterprise.

Stopping Keycloak
-----------------

.. code-block:: bash

$ make dev.stop.keycloak

The ``keycloak_data`` volume is preserved, so the next ``make dev.up.keycloak``
will resume with the same realm and user data.

Troubleshooting
---------------

**saml --pull fails during provisioning**
The provisioning script runs ``saml --pull`` to fetch metadata from *all*
enabled SAML providers. If a pre-existing ``SAMLProviderConfig`` in your
devstack points to an unreachable metadata URL, the command will fail.
Audit the provider list in the LMS Django admin at
``http://localhost:18000/admin/third_party_auth/samlproviderconfig/?show_history=1``.

**Browser cannot reach edx.devstack.keycloak**
Verify the ``/etc/hosts`` entry described above. If you are using a GitHub
Codespace or other remote environment, the entry must be on the machine
running your browser, not inside the remote environment.

**Keycloak admin console**
The Keycloak admin console is available at
``http://localhost:8080/admin/master/console/`` with credentials
``admin`` / ``admin``.

**Account MFE shows no linked providers**
UserSocialAuth likely has no row for this user -- complete the SAML
login first.
2 changes: 1 addition & 1 deletion enterprise/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,4 @@
Your project description goes here.
"""

__version__ = "8.0.4"
__version__ = "8.0.5"
22 changes: 17 additions & 5 deletions enterprise/apps.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,9 @@
"""

from django.apps import AppConfig, apps
from django.db.models.signals import post_save, pre_migrate

from enterprise.constants import USER_POST_SAVE_DISPATCH_UID
from enterprise.constants import SAML_ACCOUNT_DISCONNECTED_DISPATCH_UID, USER_POST_SAVE_DISPATCH_UID


class EnterpriseConfig(AppConfig):
Expand Down Expand Up @@ -39,16 +40,27 @@ def ready(self):
"""
Perform other one-time initialization steps.
"""
from enterprise.signals import handle_user_post_save # pylint: disable=import-outside-toplevel

from django.db.models.signals import post_save, pre_migrate # pylint: disable=C0415, # isort:skip
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As far as I can tell, importing post_save and pre_migrate never needed to be deferred. In fact, importing signals generally doesn't need to be deferred. It's just the handlers that need to be deferred because they often import models.

from enterprise.signals import ( # pylint: disable=import-outside-toplevel
handle_social_auth_disconnect,
handle_user_post_save,
)

post_save.connect(handle_user_post_save, sender=self.auth_user_model, dispatch_uid=USER_POST_SAVE_DISPATCH_UID)
pre_migrate.connect(self._disconnect_user_post_save_for_migrations)

try:
# pylint: disable=import-outside-toplevel
from common.djangoapps.third_party_auth.signals import SAMLAccountDisconnected
except ImportError:
pass
else:
SAMLAccountDisconnected.connect(
handle_social_auth_disconnect,
dispatch_uid=SAML_ACCOUNT_DISCONNECTED_DISPATCH_UID,
)

def _disconnect_user_post_save_for_migrations(self, sender, **kwargs): # pylint: disable=unused-argument
"""
Handle pre_migrate signal - disconnect User post_save handler.
"""
from django.db.models.signals import post_save # pylint: disable=import-outside-toplevel
post_save.disconnect(sender=self.auth_user_model, dispatch_uid=USER_POST_SAVE_DISPATCH_UID)
6 changes: 5 additions & 1 deletion enterprise/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,11 @@
# We listen to the User post_save signal in order to associate new users
# with an EnterpriseCustomer when applicable. This it the unique identifier
# used to ensure that signal receiver is only called once.
USER_POST_SAVE_DISPATCH_UID = "user_post_save_upgrade_pending_enterprise_customer_user"
USER_POST_SAVE_DISPATCH_UID = "enterprise.user_post_save_upgrade_pending_enterprise_customer_user"
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seems like good practice to prefix the dispatch UID with something unique.


# We listen to the SAMLAccountDisconnected signal to unlink learners from their
# enterprise identity provider.
SAML_ACCOUNT_DISCONNECTED_DISPATCH_UID = "enterprise.handle_social_auth_disconnect"


# Data sharing consent messages
Expand Down
17 changes: 16 additions & 1 deletion enterprise/settings/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"""


def plugin_settings(settings): # pylint: disable=unused-argument
def plugin_settings(settings):
"""
Override platform settings for the enterprise app.

Expand All @@ -13,3 +13,18 @@ def plugin_settings(settings): # pylint: disable=unused-argument
Args:
settings: The Django settings module being configured.
"""
pipeline = getattr(settings, 'SOCIAL_AUTH_PIPELINE', None)
if pipeline is not None:
email_step = 'enterprise.tpa_pipeline.enterprise_associate_by_email'
oauth_step = 'common.djangoapps.third_party_auth.pipeline.associate_by_email_if_oauth'
if email_step not in pipeline:
# pipeline.index() intentionally raises ValueError if the reference step is
# missing — this prevents Django from starting with a misconfigured pipeline.
pipeline.insert(pipeline.index(oauth_step), email_step)
Comment thread
pwnage101 marked this conversation as resolved.

logistration_step = 'enterprise.tpa_pipeline.handle_enterprise_logistration'
associate_step = 'social_core.pipeline.social_auth.associate_user'
if logistration_step not in pipeline:
# pipeline.index() intentionally raises ValueError if the reference step is
# missing — this prevents Django from starting with a misconfigured pipeline.
pipeline.insert(pipeline.index(associate_step) + 1, logistration_step)
7 changes: 7 additions & 0 deletions enterprise/settings/test.py
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,13 @@ def root(*args):

TIME_ZONE = 'UTC'

# Business logic should be allowed to assume that FEATURES is set. In a
# running app, it's set by the platform, but in enterprise unit tests we'll
# just seed one here.
FEATURES = {
'ENABLE_ENTERPRISE_INTEGRATION': True,
}

MKTG_URLS = {}

ENTERPRISE_CUSTOMER_CATALOG_DEFAULT_CONTENT_FILTER = {
Expand Down
Loading
Loading