Skip to content
Open
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
18 changes: 1 addition & 17 deletions openedx/core/djangoapps/user_authn/config/waffle.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
Waffle flags and switches for user authn.
"""

from edx_toggles.toggles import WaffleFlag, WaffleSwitch
from edx_toggles.toggles import WaffleSwitch

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

nice

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Indeed, perhaps you caught on, but this waffle flag has been enabled in both stage and prod for a while. It no longer serves any purpose, and upstream openedx-platform never received this waffle flag because the original PR was never upstreamed.


_WAFFLE_NAMESPACE = 'user_authn'

Expand Down Expand Up @@ -31,19 +31,3 @@
ENABLE_PWNED_PASSWORD_API = WaffleSwitch(
f'{_WAFFLE_NAMESPACE}.enable_pwned_password_api', __name__
)

# .. toggle_name: user_authn.enable_enterprise_redirect_to_authn
# .. toggle_implementation: WaffleFlag
# .. toggle_default: False
# .. toggle_description: When enabled, allows Enterprise/B2B customers to be redirected to the AuthN MFE instead of
# the legacy Django login templates. This flag provides an incremental rollout mechanism for migrating Enterprise
# customers to the modern authentication experience. The flag has no effect on users with external authentication
# providers (SAML/TPA), who always remain on the legacy flow. B2C users are redirected to the MFE by default
# regardless of this flag.
# .. toggle_use_cases: opt_in
# .. toggle_creation_date: 2026-02-18
# .. toggle_warning: This flag only affects Enterprise customers without external auth providers (SAML/TPA).
# Enabling this flag for an Enterprise customer with complex SSO requirements may break authentication flows.
ENABLE_ENTERPRISE_REDIRECT_TO_AUTHN = WaffleFlag(
f'{_WAFFLE_NAMESPACE}.enable_enterprise_redirect_to_authn', __name__
)
46 changes: 9 additions & 37 deletions openedx/core/djangoapps/user_authn/views/login.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,6 @@
import hashlib
import json
import logging
import re
import urllib

from django.conf import settings
from django.contrib.auth import authenticate, get_user_model
Expand All @@ -29,7 +27,7 @@
from eventtracking import tracker
from openedx_events.learning.data import UserData, UserPersonalData
from openedx_events.learning.signals import SESSION_LOGIN_COMPLETED
from openedx_filters.learning.filters import StudentLoginRequested
from openedx_filters.learning.filters import PostLoginRedirectURLRequested, StudentLoginRequested
from rest_framework import status
from rest_framework.views import APIView

Expand All @@ -56,11 +54,9 @@
)
from openedx.core.djangoapps.user_authn.views.login_form import get_login_session_form
from openedx.core.djangoapps.user_authn.views.password_reset import send_password_reset_email_for_user
from openedx.core.djangoapps.user_authn.views.utils import API_V1, ENTERPRISE_ENROLLMENT_URL_REGEX, UUID4_REGEX
from openedx.core.djangoapps.user_authn.views.utils import API_V1
from openedx.core.djangoapps.util.user_messages import PageLevelMessages
from openedx.core.djangolib.markup import HTML, Text
from openedx.core.lib.api.view_utils import require_post_params # lint-amnesty, pylint: disable=unused-import
from openedx.features.enterprise_support.api import activate_learner_enterprise, get_enterprise_learner_data_from_api

log = logging.getLogger("edx.student")
AUDIT_LOG = logging.getLogger("audit")
Expand Down Expand Up @@ -478,35 +474,6 @@ def finish_auth(request):
)


def enterprise_selection_page(request, user, next_url):
"""
Updates redirect url to enterprise selection page if user is associated
with multiple enterprises otherwise return the next url.

param:
next_url(string): The URL to redirect to after multiple enterprise selection or in case
the selection page is bypassed e.g when dealing with direct enrolment urls.
"""
redirect_url = next_url

response = get_enterprise_learner_data_from_api(user)
if response and len(response) > 1:
redirect_url = reverse("enterprise_select_active") + "/?success_url=" + urllib.parse.quote(next_url)

# Check to see if next url has an enterprise in it. In this case if user is associated with
# that enterprise, activate that enterprise and bypass the selection page.
if re.match(ENTERPRISE_ENROLLMENT_URL_REGEX, urllib.parse.unquote(next_url)):
enterprise_in_url = re.search(UUID4_REGEX, next_url).group(0)
for enterprise in response:
if enterprise_in_url == str(enterprise["enterprise_customer"]["uuid"]):
is_activated_successfully = activate_learner_enterprise(request, user, enterprise_in_url)
if is_activated_successfully:
redirect_url = next_url
break

return redirect_url


@ensure_csrf_cookie
@require_http_methods(["POST"])
@ratelimit(
Expand Down Expand Up @@ -648,9 +615,14 @@ def login_user(request, api_version="v1"): # pylint: disable=too-many-statement
redirect_url = finish_auth_url
elif should_redirect_to_authn_microfrontend():
next_url, root_url = get_next_url_for_login_page(request, include_host=True)
redirect_url = get_redirect_url_with_host(
root_url, enterprise_selection_page(request, possibly_authenticated_user, finish_auth_url or next_url)
# .. filter_implemented_name: PostLoginRedirectURLRequested
# .. filter_type: org.openedx.learning.auth.post_login.redirect_url.requested.v1
post_login_redirect_url, __, __ = PostLoginRedirectURLRequested.run_filter(
redirect_url=finish_auth_url or next_url,
user=possibly_authenticated_user,
next_url=finish_auth_url or next_url,
)
redirect_url = get_redirect_url_with_host(root_url, post_login_redirect_url)

if (
settings.ENABLE_AUTHN_LOGIN_NUDGE_HIBP_POLICY
Expand Down
106 changes: 40 additions & 66 deletions openedx/core/djangoapps/user_authn/views/login_form.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,11 @@
from django.views.decorators.csrf import ensure_csrf_cookie
from django.views.decorators.http import require_http_methods
from django_ratelimit.decorators import ratelimit
from openedx_filters.learning.filters import (
LoginFormTPAOverridesRequested,
LogistrationContextRequested,
LogistrationResponseRendered,
)
Comment on lines +16 to +20

from common.djangoapps import third_party_auth
from common.djangoapps.edxmako.shortcuts import render_to_response
Expand All @@ -23,18 +28,11 @@
)
from openedx.core.djangoapps.user_api.helpers import FormDescription
from openedx.core.djangoapps.user_authn.cookies import set_logged_in_cookies
from openedx.core.djangoapps.user_authn.config.waffle import ENABLE_ENTERPRISE_REDIRECT_TO_AUTHN
from openedx.core.djangoapps.user_authn.toggles import should_redirect_to_authn_microfrontend
from openedx.core.djangoapps.user_authn.views.password_reset import get_password_reset_form
from openedx.core.djangoapps.user_authn.views.registration_form import RegistrationFormFactory
from openedx.core.djangoapps.user_authn.views.utils import third_party_auth_context
from openedx.core.djangoapps.user_authn.toggles import is_require_third_party_auth_enabled
from openedx.features.enterprise_support.api import enterprise_customer_for_request, enterprise_enabled
from openedx.features.enterprise_support.utils import (
get_enterprise_slug_login_url,
handle_enterprise_cookies_for_logistration,
update_logistration_context_for_enterprise
)
from common.djangoapps.student.helpers import get_next_url_for_login_page
from common.djangoapps.third_party_auth import pipeline
from common.djangoapps.third_party_auth.decorators import xframe_allow_whitelisted
Expand All @@ -45,34 +43,32 @@

def _apply_third_party_auth_overrides(request, form_desc):
"""Modify the login form if the user has authenticated with a third-party provider.
If a user has successfully authenticated with a third-party provider,
and an email is associated with it then we fill in the email field with readonly property.

If a third-party-auth pipeline is running for the request, pipeline steps of the
LoginFormTPAOverridesRequested filter may override login form field properties
(e.g. pre-fill the email field) based on the running pipeline and its provider.

Arguments:
request (HttpRequest): The request for the registration form, used
request (HttpRequest): The request for the login form, used
to determine if the user has successfully authenticated
with a third-party provider.
form_desc (FormDescription): The registration form description
form_desc (FormDescription): The login form description

Returns:
FormDescription: the (possibly modified) login form description
"""
if third_party_auth.is_enabled():
running_pipeline = third_party_auth.pipeline.get(request)
if running_pipeline:
current_provider = third_party_auth.provider.Registry.get_from_pipeline(running_pipeline)
if current_provider and enterprise_customer_for_request(request):
pipeline_kwargs = running_pipeline.get('kwargs')

# Details about the user sent back from the provider.
details = pipeline_kwargs.get('details')
email = details.get('email', '')

# override the email field.
form_desc.override_field_properties(
"email",
default=email,
restrictions={"readonly": "readonly"} if email else {
"min_length": accounts.EMAIL_MIN_LENGTH,
"max_length": accounts.EMAIL_MAX_LENGTH,
}
)
# .. filter_implemented_name: LoginFormTPAOverridesRequested
# .. filter_type: org.openedx.learning.login.form.tpa_overrides.requested.v1
form_desc, *_ = LoginFormTPAOverridesRequested.run_filter(
form_desc=form_desc,
running_pipeline=running_pipeline,
current_provider=current_provider,
)
return form_desc


def get_login_session_form(request):
Expand All @@ -90,7 +86,10 @@ def get_login_session_form(request):

"""
form_desc = FormDescription("post", reverse("user_api_login_session", kwargs={'api_version': 'v1'}))
_apply_third_party_auth_overrides(request, form_desc)

# Field property overrides applied by pipeline steps take effect when the fields are
# added below, so the overrides must be applied before the fields are added.
form_desc = _apply_third_party_auth_overrides(request, form_desc)

# Translators: This label appears above a field on the login form
# meant to hold the user's email address.
Expand Down Expand Up @@ -213,42 +212,15 @@ def login_and_registration_form(request, initial_mode="login"):
except (KeyError, ValueError, IndexError) as ex:
log.exception("Unknown tpa_hint provider: %s", ex)

# Redirect to authn MFE if it is enabled
# AND
# user is not an enterprise user
# AND
# tpa_hint_provider is not available
# AND
# user is not coming from a SAML IDP.

enterprise_customer = enterprise_customer_for_request(request)

# Check for external providers (SAML/TPA) which must NEVER redirect to MFE
has_external_provider = bool(tpa_hint_provider or saml_provider)

# Determine eligibility based on segment
if enterprise_customer:
# Enterprise/B2B: Requires the specific rollout waffle flag
is_segment_eligible = ENABLE_ENTERPRISE_REDIRECT_TO_AUTHN.is_enabled()
else:
# B2C: Eligible by default
is_segment_eligible = True

# Redirect to authn MFE if all conditions are met:
# 1. MFE is globally enabled (should_redirect_to_authn_microfrontend)
# 2. User segment is eligible (B2C by default, or Enterprise with flag enabled)
# 3. No external auth provider is present (SAML/TPA must use legacy flow)
if should_redirect_to_authn_microfrontend() and \
is_segment_eligible and \
not has_external_provider:

# This is to handle a case where a logged-in cookie is not present but the user is authenticated.
# Note: If we don't handle this learner is redirected to authn MFE and then back to dashboard
# instead of the desired redirect URL (e.g. finish_auth) resulting in learners not enrolling
# into the courses.
if request.user.is_authenticated and redirect_to:
return redirect(redirect_to)

# Redirect to the authn MFE when it is enabled, UNLESS auth is provided externally.
#
# NOTE: SAML/TPA users authenticating externally must remain on the legacy login/registration page because the
# new AuthN MFE does not yet fully support provider branding, hinted-login dialog, etc.
#
# TODO: Remove the `has_external_provider` check as soon as the AuthN MFE is fully-featured.
if should_redirect_to_authn_microfrontend() and not has_external_provider:
query_params = request.GET.urlencode()
url_path = '/{}{}'.format(
initial_mode,
Expand Down Expand Up @@ -295,8 +267,6 @@ def login_and_registration_form(request, initial_mode="login"):
'ALLOW_PUBLIC_ACCOUNT_CREATION', settings.FEATURES.get('ALLOW_PUBLIC_ACCOUNT_CREATION', True)),
'register_links_allowed': settings.FEATURES.get('SHOW_REGISTRATION_LINKS', True),
'is_account_recovery_feature_enabled': is_secondary_email_feature_enabled(),
'enterprise_slug_login_url': get_enterprise_slug_login_url(),
'is_enterprise_enable': enterprise_enabled(),
'is_require_third_party_auth_enabled': is_require_third_party_auth_enabled(),
'enable_coppa_compliance': settings.ENABLE_COPPA_COMPLIANCE,
'edx_user_info_cookie_name': settings.EDXMKTG_USER_INFO_COOKIE_NAME,
Expand All @@ -312,11 +282,15 @@ def login_and_registration_form(request, initial_mode="login"):
),
}

update_logistration_context_for_enterprise(request, context, enterprise_customer)
# .. filter_implemented_name: LogistrationContextRequested
# .. filter_type: org.openedx.learning.logistration.context.requested.v1
context = LogistrationContextRequested.run_filter(context=context)

response = render_to_response('student_account/login_and_register.html', context)
handle_enterprise_cookies_for_logistration(request, response, context)

# .. filter_implemented_name: LogistrationResponseRendered
# .. filter_type: org.openedx.learning.logistration.response.rendered.v1
response, __ = LogistrationResponseRendered.run_filter(response=response, context=context)
return response


Expand Down
32 changes: 21 additions & 11 deletions openedx/core/djangoapps/user_authn/views/registration_form.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
from django.utils.translation import gettext as _
from django_countries import countries
from eventtracking import tracker
from openedx_filters.learning.filters import RegistrationFormTPAOverridesRequested

from common.djangoapps import third_party_auth
from common.djangoapps.third_party_auth.models import SAMLProviderConfig
Expand All @@ -35,7 +36,6 @@
from openedx.core.djangoapps.user_authn.utils import is_registration_api_v1 as is_api_v1
from openedx.core.djangoapps.user_authn.views.utils import remove_disabled_country_from_list
from openedx.core.djangolib.markup import HTML, Text
from openedx.features.enterprise_support.api import enterprise_customer_for_request


log = logging.getLogger(__name__)
Expand Down Expand Up @@ -473,7 +473,11 @@ def get_registration_form(self, request):
"""
self.request = request
form_desc = FormDescription("post", self._get_registration_submit_url(request))
self._apply_third_party_auth_overrides(request, form_desc)

# Field property overrides applied by third-party-auth pipeline steps take effect
# when the fields are added below, so the overrides must be applied before the
# field loop.
form_desc = self._apply_third_party_auth_overrides(request, form_desc)

# Custom form fields can be added via the form set in settings.REGISTRATION_EXTENSION_FORM
custom_form = get_registration_extension_form()
Expand Down Expand Up @@ -1190,11 +1194,15 @@ def _apply_third_party_auth_overrides(self, request, form_desc):
This will also hide the password field, since we assign users a default
(random) password on the assumption that they will be using
third-party auth to log in.
Finally, pipeline steps of the RegistrationFormTPAOverridesRequested filter may
apply further overrides based on the running pipeline and its provider.
Arguments:
request (HttpRequest): The request for the registration form, used
to determine if the user has successfully authenticated
with a third-party provider.
form_desc (FormDescription): The registration form description
Returns:
FormDescription: the (possibly modified) registration form description
"""
# pylint: disable=too-many-nested-blocks
if third_party_auth.is_enabled():
Expand All @@ -1208,15 +1216,7 @@ def _apply_third_party_auth_overrides(self, request, form_desc):
running_pipeline.get('kwargs')
)

# When the TPA Provider is configured to skip the registration form and we are in an
# enterprise context, we need to hide all fields except for terms of service and
# ensure that the user explicitly checks that field.
# pylint: disable=consider-using-ternary
hide_registration_fields_except_tos = (
(
current_provider.skip_registration_form and enterprise_customer_for_request(request)
) or current_provider.sync_learner_profile_data
)
hide_registration_fields_except_tos = current_provider.sync_learner_profile_data

for field_name in self.DEFAULT_FIELDS + self.EXTRA_FIELDS:
if field_name not in field_overrides:
Expand Down Expand Up @@ -1293,3 +1293,13 @@ def _apply_third_party_auth_overrides(self, request, form_desc):
default=current_provider.name if current_provider.name else "Third Party",
required=False,
)

# .. filter_implemented_name: RegistrationFormTPAOverridesRequested
# .. filter_type: org.openedx.learning.registration.form.tpa_overrides.requested.v1
form_desc, *_ = RegistrationFormTPAOverridesRequested.run_filter(
form_desc=form_desc,
running_pipeline=running_pipeline,
current_provider=current_provider,
)

return form_desc
Loading
Loading