Skip to content

Commit 0049ae5

Browse files
committed
feat: make marketing email and research opt-in checkboxs selectively ignorable
We want to support a flow for SSO-enabled Enterprise customers who have agreed off-platform that none of their learners will opt-in to marketing emails or sharing research data. This change proposes to do so by adding an optional field that, when enabled, disables the presence of the two checkboxes on this registration form and sets their values to false. ENT-11401
1 parent 85e81b3 commit 0049ae5

10 files changed

Lines changed: 851 additions & 28 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ lms/envs/private.py
1313
cms/envs/private.py
1414
.venv/
1515
CLAUDE.md
16+
.claude/
1617
AGENTS.md
1718
# end-noclean
1819

Lines changed: 334 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,334 @@
1+
Testing SAML Authentication Locally with MockSAML
2+
==================================================
3+
4+
This guide walks through setting up and testing SAML authentication in a local Open edX devstack environment using MockSAML.com as a test Identity Provider (IdP).
5+
6+
Overview
7+
--------
8+
9+
SAML (Security Assertion Markup Language) authentication in Open edX requires three configuration objects to work together:
10+
11+
1. **SAMLConfiguration**: Configures the Service Provider (SP) metadata - entity ID, keys, and organization info
12+
2. **SAMLProviderConfig**: Configures a specific Identity Provider (IdP) connection with metadata URL and attribute mappings
13+
3. **SAMLProviderData**: Stores the IdP's metadata (SSO URL, public key) fetched from the IdP's metadata endpoint
14+
15+
**Critical Requirement**: The SAMLConfiguration object MUST have the slug "default" because this value is hardcoded in the authentication execution path at ``common/djangoapps/third_party_auth/models.py:906``.
16+
17+
Prerequisites
18+
-------------
19+
20+
* Local Open edX devstack running
21+
* Access to Django admin at http://localhost:18000/admin/
22+
* MockSAML.com account (free service for SAML testing)
23+
24+
Step 1: Configure SAMLConfiguration
25+
------------------------------------
26+
27+
The SAMLConfiguration defines your Open edX instance as a SAML Service Provider (SP).
28+
29+
1. Navigate to Django Admin → Third Party Auth → SAML Configurations
30+
2. Click "Add SAML Configuration"
31+
3. Configure with these **required** values:
32+
33+
============ ===================================================
34+
Field Value
35+
============ ===================================================
36+
Site localhost:18000
37+
**Slug** **default** (MUST be "default" - hardcoded in code)
38+
Entity ID https://saml.example.com/entityid
39+
Enabled ✓ (checked)
40+
============ ===================================================
41+
42+
4. Generate a key pair for signing/encryption (optional for testing):
43+
44+
.. code-block:: bash
45+
46+
openssl req -new -x509 -days 3652 -nodes -out saml.crt -keyout saml.key
47+
48+
Then paste the contents of ``saml.key`` into "Private Key" and ``saml.crt`` into "Public Key" (the headers/footers will be stripped automatically).
49+
50+
**Note**: For local testing with MockSAML, you can leave the keys blank.
51+
52+
5. Configure Organization Info (use default or customize):
53+
54+
.. code-block:: json
55+
56+
{
57+
"en-US": {
58+
"url": "http://localhost:18000",
59+
"displayname": "Local Open edX",
60+
"name": "localhost"
61+
}
62+
}
63+
64+
6. Click "Save"
65+
66+
Step 2: Configure SAMLProviderConfig
67+
-------------------------------------
68+
69+
The SAMLProviderConfig connects to a specific SAML Identity Provider (MockSAML in this case).
70+
71+
1. Navigate to Django Admin → Third Party Auth → Provider Configuration (SAML IdPs)
72+
2. Click "Add Provider Configuration (SAML IdP)"
73+
3. Configure with these values:
74+
75+
========================= ===================================================
76+
Field Value
77+
========================= ===================================================
78+
Name Test Localhost (or any descriptive name)
79+
Slug default (to match test URLs)
80+
Backend Name tpa-saml
81+
Entity ID https://saml.example.com/entityid
82+
Metadata Source https://mocksaml.com/api/saml/metadata
83+
Site localhost:18000
84+
SAML Configuration Select the SAMLConfiguration created in Step 1
85+
Enabled ✓ (checked)
86+
Visible ☐ (unchecked for testing)
87+
Skip hinted login dialog ✓ (checked - recommended)
88+
Skip registration form ✓ (checked - recommended)
89+
Skip email verification ✓ (checked - recommended)
90+
Send to registration first ✓ (checked - recommended)
91+
========================= ===================================================
92+
93+
4. Leave all attribute mappings (User ID, Email, Full Name, etc.) blank to use defaults
94+
5. Click "Save"
95+
96+
**Important**: The Entity ID in SAMLProviderConfig MUST match the Entity ID in SAMLConfiguration.
97+
98+
Step 3: Fetch IdP Metadata
99+
---------------------------
100+
101+
The SAMLProviderData stores metadata from the Identity Provider (MockSAML).
102+
103+
Run the SAML pull command to fetch IdP metadata:
104+
105+
.. code-block:: bash
106+
107+
docker exec edx.devstack.lms bash -c "python manage.py lms saml pull"
108+
109+
This command will:
110+
111+
* Fetch metadata from https://mocksaml.com/api/saml/metadata
112+
* Extract the SSO URL and public key certificate
113+
* Create a SAMLProviderData record with:
114+
115+
- **Entity ID**: https://saml.example.com/entityid
116+
- **SSO URL**: https://mocksaml.com/api/saml/sso
117+
- **Public Key**: The IdP's signing certificate
118+
- **Expires At**: Set to 1 year from fetch time
119+
120+
Verify the data was fetched successfully:
121+
122+
.. code-block:: bash
123+
124+
docker exec edx.devstack.lms bash -c "python manage.py lms shell -c \\\"
125+
from common.djangoapps.third_party_auth.models import SAMLProviderData
126+
data = SAMLProviderData.objects.filter(entity_id='https://saml.example.com/entityid').first()
127+
print(f'SSO URL: {data.sso_url}')
128+
print(f'Is Valid: {data.is_valid()}')
129+
\\\""
130+
131+
Expected output:
132+
133+
.. code-block:: text
134+
135+
SSO URL: https://mocksaml.com/api/saml/sso
136+
Is Valid: True
137+
138+
Step 4: Configure MockSAML.com
139+
-------------------------------
140+
141+
1. Go to https://mocksaml.com/
142+
2. Log in or create a free account
143+
3. Configure the SP (Service Provider) settings:
144+
145+
=================== ===================================================
146+
Field Value
147+
=================== ===================================================
148+
SP Entity ID https://saml.example.com/entityid
149+
ACS URL http://localhost:18000/auth/complete/tpa-saml/
150+
Single Logout URL http://localhost:18000/auth/complete/tpa-saml/
151+
=================== ===================================================
152+
153+
4. Configure test user attributes in MockSAML:
154+
155+
* **Email**: Use any valid email format (e.g., [email protected])
156+
* **First Name**: Test
157+
* **Last Name**: User
158+
* **UID**: testuser123 (unique identifier)
159+
160+
Step 5: Test SAML Authentication
161+
---------------------------------
162+
163+
Method 1: Direct Login URL
164+
^^^^^^^^^^^^^^^^^^^^^^^^^^^
165+
166+
Navigate to the SAML login URL with the IdP hint:
167+
168+
.. code-block:: text
169+
170+
http://localhost:18000/login?tpa_hint=saml-default
171+
172+
This will redirect to MockSAML for authentication.
173+
174+
Method 2: TPA Test Page
175+
^^^^^^^^^^^^^^^^^^^^^^^
176+
177+
1. Navigate to: http://localhost:18000/auth/login/tpa-saml/?auth_entry=login&idp=default
178+
2. You should be redirected to MockSAML.com
179+
3. Complete the authentication on MockSAML
180+
4. You should be redirected back to Open edX
181+
5. If this is a new user, you'll see the registration form
182+
6. After registration, you should be logged in
183+
184+
Expected Behavior
185+
^^^^^^^^^^^^^^^^^
186+
187+
1. Initial redirect to MockSAML (https://mocksaml.com/api/saml/sso)
188+
2. MockSAML displays the login page
189+
3. After authentication, MockSAML POSTs the SAML assertion back to Open edX
190+
4. Open edX validates the assertion and creates/logs in the user
191+
5. User is redirected to the dashboard or registration form (if new user)
192+
193+
Troubleshooting
194+
---------------
195+
196+
Common Issues
197+
^^^^^^^^^^^^^
198+
199+
**SAMLConfiguration slug is not "default"**
200+
The slug MUST be "default" for the code to find it. See line 906 in ``models.py``:
201+
202+
.. code-block:: python
203+
204+
conf['saml_sp_configuration'] = (
205+
self.saml_configuration or
206+
SAMLConfiguration.current(self.site.id, 'default') # Hardcoded!
207+
)
208+
209+
**Entity IDs don't match**
210+
Ensure all three configurations use the same Entity ID:
211+
212+
* SAMLConfiguration.entity_id
213+
* SAMLProviderConfig.entity_id
214+
* SAMLProviderData.entity_id
215+
* MockSAML SP Entity ID
216+
217+
**SAMLProviderData not found**
218+
Run the pull command again:
219+
220+
.. code-block:: bash
221+
222+
docker exec edx.devstack.lms bash -c "python manage.py lms saml pull"
223+
224+
**ACS URL mismatch**
225+
Verify the Assertion Consumer Service (ACS) URL in MockSAML matches:
226+
227+
.. code-block:: text
228+
229+
http://localhost:18000/auth/complete/tpa-saml/
230+
231+
**Debug Mode**
232+
Enable debug mode in SAMLProviderConfig to see all SAML XML requests/responses in logs:
233+
234+
1. Edit your SAMLProviderConfig in Django admin
235+
2. Check "Debug Mode"
236+
3. Save and test again
237+
4. Check logs: ``docker logs edx.devstack.lms``
238+
239+
**Warning**: Disable debug mode before production use!
240+
241+
Verifying Configuration
242+
^^^^^^^^^^^^^^^^^^^^^^^^
243+
244+
Check that all three objects are configured correctly:
245+
246+
.. code-block:: bash
247+
248+
docker exec edx.devstack.lms bash -c "python manage.py lms shell -c \\\"
249+
from common.djangoapps.third_party_auth.models import SAMLProviderConfig, SAMLConfiguration, SAMLProviderData
250+
251+
# Check SAMLConfiguration
252+
config = SAMLConfiguration.objects.filter(site__domain='localhost:18000', slug='default').first()
253+
print(f'SAMLConfiguration: {config}')
254+
print(f' Entity ID: {config.entity_id if config else None}')
255+
print(f' Enabled: {config.enabled if config else None}')
256+
print()
257+
258+
# Check SAMLProviderConfig
259+
provider = SAMLProviderConfig.objects.filter(slug='default', site__domain='localhost:18000').first()
260+
print(f'SAMLProviderConfig: {provider}')
261+
print(f' Entity ID: {provider.entity_id if provider else None}')
262+
print(f' Metadata: {provider.metadata_source if provider else None}')
263+
print(f' Enabled: {provider.enabled if provider else None}')
264+
print()
265+
266+
# Check SAMLProviderData
267+
data = SAMLProviderData.objects.filter(entity_id='https://saml.example.com/entityid').first()
268+
print(f'SAMLProviderData: {data.id if data else None}')
269+
print(f' SSO URL: {data.sso_url if data else None}')
270+
print(f' Valid: {data.is_valid() if data else None}')
271+
\\\""
272+
273+
Expected output should show all three objects with matching entity IDs and enabled status.
274+
275+
Testing with Different Entity IDs
276+
----------------------------------
277+
278+
If you need to test with a different entity ID (not ``https://saml.example.com/entityid``):
279+
280+
1. Update **all three** configurations to use the same new entity ID:
281+
282+
* SAMLConfiguration → Entity ID
283+
* SAMLProviderConfig → Entity ID
284+
* Re-run ``manage.py lms saml pull`` to fetch new SAMLProviderData
285+
286+
2. Update MockSAML SP Entity ID to match
287+
288+
3. Ensure all URLs and slugs remain consistent
289+
290+
Related Documentation
291+
---------------------
292+
293+
* SAML Architecture: ``docs/decisions/`` (search for SAML-related ADRs)
294+
* Model Definitions: ``common/djangoapps/third_party_auth/models.py``
295+
* SAML Backend: ``common/djangoapps/third_party_auth/saml.py``
296+
* Management Command: ``common/djangoapps/third_party_auth/management/commands/saml.py``
297+
298+
Reference Configuration
299+
-----------------------
300+
301+
Here's a summary of a working test configuration:
302+
303+
**SAMLConfiguration** (id=6):
304+
305+
* Site: localhost:18000
306+
* Slug: **default**
307+
* Entity ID: https://saml.example.com/entityid
308+
* Enabled: True
309+
310+
**SAMLProviderConfig** (id=11):
311+
312+
* Name: Test Localhost
313+
* Slug: default
314+
* Entity ID: https://saml.example.com/entityid
315+
* Metadata Source: https://mocksaml.com/api/saml/metadata
316+
* Backend Name: tpa-saml
317+
* Site: localhost:18000
318+
* SAML Configuration: → SAMLConfiguration (id=6)
319+
* Enabled: True
320+
321+
**SAMLProviderData** (id=3):
322+
323+
* Entity ID: https://saml.example.com/entityid
324+
* SSO URL: https://mocksaml.com/api/saml/sso
325+
* Public Key: (certificate from MockSAML metadata)
326+
* Fetched At: 2026-02-27 18:05:40+00:00
327+
* Expires At: 2027-02-27 18:05:41+00:00
328+
* Valid: True
329+
330+
**MockSAML Configuration**:
331+
332+
* SP Entity ID: https://saml.example.com/entityid
333+
* ACS URL: http://localhost:18000/auth/complete/tpa-saml/
334+
* Test User Attributes: email, firstName, lastName, uid
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
# Generated migration for adding optional checkbox skip configuration field
2+
3+
from django.db import migrations, models
4+
import django.utils.translation
5+
6+
7+
class Migration(migrations.Migration):
8+
9+
dependencies = [
10+
('third_party_auth', '0013_default_site_id_wrapper_function'),
11+
]
12+
13+
operations = [
14+
migrations.AddField(
15+
model_name='samlproviderconfig',
16+
name='skip_registration_optional_checkboxes',
17+
field=models.BooleanField(
18+
default=False,
19+
help_text=django.utils.translation.gettext_lazy(
20+
"If enabled, optional checkboxes (marketing emails opt-in, etc.) will not be rendered "
21+
"on the registration form for users registering via this provider. When these checkboxes "
22+
"are skipped, their values are inferred as False (opted out)."
23+
),
24+
),
25+
),
26+
]

common/djangoapps/third_party_auth/models.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -745,6 +745,14 @@ class SAMLProviderConfig(ProviderConfig):
745745
"immediately after authenticating with the third party instead of the login page."
746746
),
747747
)
748+
skip_registration_optional_checkboxes = models.BooleanField(
749+
default=False,
750+
help_text=_(
751+
"If enabled, optional checkboxes (marketing emails opt-in, etc.) will not be rendered "
752+
"on the registration form for users registering via this provider. When these checkboxes "
753+
"are skipped, their values are inferred as False (opted out)."
754+
),
755+
)
748756
other_settings = models.TextField(
749757
verbose_name="Advanced settings", blank=True,
750758
help_text=(

0 commit comments

Comments
 (0)