From fdd2ea0582340f3e2319a198394700efdce522bb Mon Sep 17 00:00:00 2001 From: Alexander Dusenbery Date: Mon, 23 Feb 2026 11:11:14 -0500 Subject: [PATCH] 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 --- .gitignore | 1 + .../docs/how_tos/testing_saml_locally.rst | 156 +++++++++ ...roviderconfig_optional_email_checkboxes.py | 26 ++ common/djangoapps/third_party_auth/models.py | 8 + .../js/student_account/views/RegisterView.js | 2 + .../student_account/register.underscore | 18 +- .../user_authn/views/registration_form.py | 157 ++++++++- .../views/tests/test_logistration.py | 6 +- .../tests/test_saml_optional_checkboxes.py | 318 ++++++++++++++++++ .../core/djangoapps/user_authn/views/utils.py | 9 +- 10 files changed, 673 insertions(+), 28 deletions(-) create mode 100644 common/djangoapps/third_party_auth/docs/how_tos/testing_saml_locally.rst create mode 100644 common/djangoapps/third_party_auth/migrations/0014_samlproviderconfig_optional_email_checkboxes.py create mode 100644 openedx/core/djangoapps/user_authn/views/tests/test_saml_optional_checkboxes.py diff --git a/.gitignore b/.gitignore index 57e4ed34104d..069d8ebe3635 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,7 @@ lms/envs/private.py cms/envs/private.py .venv/ CLAUDE.md +.claude/ AGENTS.md # end-noclean diff --git a/common/djangoapps/third_party_auth/docs/how_tos/testing_saml_locally.rst b/common/djangoapps/third_party_auth/docs/how_tos/testing_saml_locally.rst new file mode 100644 index 000000000000..2be1cc1dacf6 --- /dev/null +++ b/common/djangoapps/third_party_auth/docs/how_tos/testing_saml_locally.rst @@ -0,0 +1,156 @@ +Testing SAML Authentication Locally with MockSAML +================================================== + +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). + +Overview +-------- + +SAML (Security Assertion Markup Language) authentication in Open edX requires three configuration objects to work together: + +1. **SAMLConfiguration**: Configures the Service Provider (SP) metadata - entity ID, keys, and organization info +2. **SAMLProviderConfig**: Configures a specific Identity Provider (IdP) connection with metadata URL and attribute mappings +3. **SAMLProviderData**: Stores the IdP's metadata (SSO URL, public key) fetched from the IdP's metadata endpoint + +**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``. + +Prerequisites +------------- + +* Local Open edX devstack running +* Access to Django admin at http://localhost:18000/admin/ +* MockSAML.com account (free service for SAML testing) + +Step 1: Configure SAMLConfiguration +------------------------------------ + +The SAMLConfiguration defines your Open edX instance as a SAML Service Provider (SP). + +1. Navigate to Django Admin → Third Party Auth → SAML Configurations +2. Click "Add SAML Configuration" +3. Configure with these **required** values: + + ============ =================================================== + Field Value + ============ =================================================== + Site localhost:18000 + **Slug** **default** (MUST be "default" - hardcoded in code) + Entity ID https://saml.example.com/entityid + Enabled ✓ (checked) + ============ =================================================== + +4. For local testing with MockSAML, you can leave the keys blank. + +5. Optionally configure Organization Info (use default or customize): + + .. code-block:: json + + { + "en-US": { + "url": "http://localhost:18000", + "displayname": "Local Open edX", + "name": "localhost" + } + } + +6. Click "Save" + +Step 2: Configure SAMLProviderConfig +------------------------------------- + +The SAMLProviderConfig connects to a specific SAML Identity Provider (MockSAML in this case). + +1. Navigate to Django Admin → Third Party Auth → Provider Configuration (SAML IdPs) +2. Click "Add Provider Configuration (SAML IdP)" +3. Configure with these values: + + ========================= =================================================== + Field Value + ========================= =================================================== + Name Test Localhost (or any descriptive name) + Slug default (to match test URLs) + Backend Name tpa-saml + Entity ID https://saml.example.com/entityid + Metadata Source https://mocksaml.com/api/saml/metadata + Site localhost:18000 + SAML Configuration Select the SAMLConfiguration created in Step 1 + Enabled ✓ (checked) + Visible ☐ (unchecked for testing) + Skip hinted login dialog ✓ (checked - recommended) + Skip registration form ✓ (checked - recommended) + Skip email verification ✓ (checked - recommended) + Send to registration first ✓ (checked - recommended) + ========================= =================================================== + +4. Leave all attribute mappings (User ID, Email, Full Name, etc.) blank to use defaults +5. Click "Save" + +**Important**: The Entity ID in SAMLProviderConfig MUST match the Entity ID in SAMLConfiguration. + +Step 3: Set IdP Data +-------------------- + +The SAMLProviderData stores metadata from the Identity Provider (MockSAML), create a record with + +* **Entity ID**: https://saml.example.com/entityid +* **SSO URL**: https://mocksaml.com/api/saml/sso +* **Public Key**: The IdP's signing certificate +* **Expires At**: Set to 1 year from fetch time + + +Step 4: Test SAML Authentication +--------------------------------- + +1. Navigate to: http://localhost:18000/auth/idp_redirect/saml-default +2. You should be redirected to MockSAML.com +3. Complete the authentication on MockSAML - just click "Sign In" with whatever is in the form. +4. You should be redirected back to Open edX +5. If this is a new user, you'll see the registration form +6. After registration, you should be logged in + +Expected Behavior +^^^^^^^^^^^^^^^^^ + +1. Initial redirect to MockSAML (https://mocksaml.com/api/saml/sso) +2. MockSAML displays the login page +3. After authentication, MockSAML POSTs the SAML assertion back to Open edX +4. Open edX validates the assertion and creates/logs in the user +5. User is redirected to the dashboard or registration form (if new user) + +Reference Configuration +----------------------- + +Here's a summary of a working test configuration: + +**SAMLConfiguration** (id=6): + +* Site: localhost:18000 +* Slug: **default** +* Entity ID: https://saml.example.com/entityid +* Enabled: True + +**SAMLProviderConfig** (id=11): + +* Name: Test Localhost +* Slug: default +* Entity ID: https://saml.example.com/entityid +* Metadata Source: https://mocksaml.com/api/saml/metadata +* Backend Name: tpa-saml +* Site: localhost:18000 +* SAML Configuration: → SAMLConfiguration (id=6) +* Enabled: True + +**SAMLProviderData** (id=3): + +* Entity ID: https://saml.example.com/entityid +* SSO URL: https://mocksaml.com/api/saml/sso +* Public Key: (certificate from MockSAML metadata) +* Fetched At: 2026-02-27 18:05:40+00:00 +* Expires At: 2027-02-27 18:05:41+00:00 +* Valid: True + +**MockSAML Configuration**: + +* SP Entity ID: https://saml.example.com/entityid +* ACS URL: http://localhost:18000/auth/complete/tpa-saml/ +* Test User Attributes: email, firstName, lastName, uid diff --git a/common/djangoapps/third_party_auth/migrations/0014_samlproviderconfig_optional_email_checkboxes.py b/common/djangoapps/third_party_auth/migrations/0014_samlproviderconfig_optional_email_checkboxes.py new file mode 100644 index 000000000000..34fcf3c97b58 --- /dev/null +++ b/common/djangoapps/third_party_auth/migrations/0014_samlproviderconfig_optional_email_checkboxes.py @@ -0,0 +1,26 @@ +# Generated migration for adding optional checkbox skip configuration field + +from django.db import migrations, models +import django.utils.translation + + +class Migration(migrations.Migration): + + dependencies = [ + ('third_party_auth', '0013_default_site_id_wrapper_function'), + ] + + operations = [ + migrations.AddField( + model_name='samlproviderconfig', + name='skip_registration_optional_checkboxes', + field=models.BooleanField( + default=False, + help_text=django.utils.translation.gettext_lazy( + "If enabled, optional checkboxes (marketing emails opt-in, etc.) will not be rendered " + "on the registration form for users registering via this provider. When these checkboxes " + "are skipped, their values are inferred as False (opted out)." + ), + ), + ), + ] diff --git a/common/djangoapps/third_party_auth/models.py b/common/djangoapps/third_party_auth/models.py index ceab0fa8c711..d3beafb4db88 100644 --- a/common/djangoapps/third_party_auth/models.py +++ b/common/djangoapps/third_party_auth/models.py @@ -745,6 +745,14 @@ class SAMLProviderConfig(ProviderConfig): "immediately after authenticating with the third party instead of the login page." ), ) + skip_registration_optional_checkboxes = models.BooleanField( + default=False, + help_text=_( + "If enabled, optional checkboxes (marketing emails opt-in, etc.) will not be rendered " + "on the registration form for users registering via this provider. When these checkboxes " + "are skipped, their values are inferred as False (opted out)." + ), + ) other_settings = models.TextField( verbose_name="Advanced settings", blank=True, help_text=( diff --git a/lms/static/js/student_account/views/RegisterView.js b/lms/static/js/student_account/views/RegisterView.js index 42ab7c8857a8..bd958c2d8bd4 100644 --- a/lms/static/js/student_account/views/RegisterView.js +++ b/lms/static/js/student_account/views/RegisterView.js @@ -58,6 +58,7 @@ ); this.currentProvider = data.thirdPartyAuth.currentProvider || ''; this.syncLearnerProfileData = data.thirdPartyAuth.syncLearnerProfileData || false; + this.skipRegistrationOptionalCheckboxes = data.thirdPartyAuth.skipRegistrationOptionalCheckboxes || false; this.errorMessage = data.thirdPartyAuth.errorMessage || ''; this.platformName = data.platformName; this.autoSubmit = data.thirdPartyAuth.autoSubmitRegForm; @@ -156,6 +157,7 @@ fields: fields, currentProvider: this.currentProvider, syncLearnerProfileData: this.syncLearnerProfileData, + skipRegistrationOptionalCheckboxes: this.skipRegistrationOptionalCheckboxes, providers: this.providers, hasSecondaryProviders: this.hasSecondaryProviders, platformName: this.platformName, diff --git a/lms/templates/student_account/register.underscore b/lms/templates/student_account/register.underscore index 84cdb09d5ce1..1822bafc7b64 100644 --- a/lms/templates/student_account/register.underscore +++ b/lms/templates/student_account/register.underscore @@ -56,14 +56,16 @@
<%= context.fields /* xss-lint: disable=underscore-not-escaped */ %> -
- - -
+ <% if (!context.skipRegistrationOptionalCheckboxes) { %> +
+ + +
+ <% } %>