Fix: CORS issues in third-party auth disconnect by adding JSON endpoint (#37100)

Add a json auth endpoint where previously there was only an HTML redirect version. This will make it easier to work with MFEs.

---------

Co-authored-by: Feanil Patel <feanil@axim.org>
This commit is contained in:
wgu-jesse-stewart
2025-09-15 10:07:49 -04:00
committed by GitHub
parent c35d3267b6
commit 254dd2f689
5 changed files with 124 additions and 5 deletions

View File

@@ -419,7 +419,7 @@ class TestThirdPartyAuthUserStatusView(ThirdPartyAuthTestMixin, APITestCase):
assert (response.data ==
[{
'accepts_logins': True, 'name': 'Google',
'disconnect_url': '/auth/disconnect/google-oauth2/?',
'disconnect_url': '/auth/disconnect_json/google-oauth2/?',
'connect_url': f'/auth/login/google-oauth2/?auth_entry=account_settings&next={next_url}',
'connected': False, 'id': 'oa2-google-oauth2'
}])

View File

@@ -379,10 +379,13 @@ def get_disconnect_url(provider_id, association_id):
ValueError: if no provider is enabled with the given ID.
"""
backend_name = _get_enabled_provider(provider_id).backend_name
# Use custom JSON disconnect endpoint to avoid CORS issues
if association_id:
return _get_url('social:disconnect_individual', backend_name, url_params={'association_id': association_id})
return _get_url(
'custom_disconnect_json_individual', backend_name, url_params={'association_id': association_id}
)
else:
return _get_url('social:disconnect', backend_name)
return _get_url('custom_disconnect_json', backend_name)
def get_login_url(provider_id, auth_entry, redirect_url=None):

View File

@@ -176,7 +176,7 @@ class UrlFormationTestCase(TestCase):
def test_disconnect_url_returns_expected_format(self):
disconnect_url = pipeline.get_disconnect_url(self.enabled_provider.provider_id, 1000)
disconnect_url = disconnect_url.rstrip('?')
assert disconnect_url == '/auth/disconnect/{backend}/{association_id}/'\
assert disconnect_url == '/auth/disconnect_json/{backend}/{association_id}/'\
.format(backend=self.enabled_provider.backend_name, association_id=1000)
def test_login_url_raises_value_error_if_provider_not_enabled(self):

View File

@@ -5,6 +5,7 @@ from django.urls import path, re_path
from .views import (
IdPRedirectView,
disconnect_json_view,
inactive_user_view,
lti_login_and_complete_view,
post_to_custom_auth_form,
@@ -17,6 +18,13 @@ urlpatterns = [
re_path(r'^auth/saml/metadata.xml', saml_metadata_view),
re_path(r'^auth/login/(?P<backend>lti)/$', lti_login_and_complete_view),
path('auth/idp_redirect/<slug:provider_slug>', IdPRedirectView.as_view(), name="idp_redirect"),
# Custom JSON disconnect endpoint to avoid CORS issues
re_path(r'^auth/disconnect_json/(?P<backend>[^/]+)/$', disconnect_json_view, name='custom_disconnect_json'),
re_path(
r'^auth/disconnect_json/(?P<backend>[^/]+)/(?P<association_id>\d+)/$',
disconnect_json_view,
name='custom_disconnect_json_individual'
),
path('auth/', include('social_django.urls', namespace='social')),
path('auth/saml/v0/', include('common.djangoapps.third_party_auth.samlproviderconfig.urls')),
path('auth/saml/v0/', include('common.djangoapps.third_party_auth.samlproviderdata.urls')),

View File

@@ -2,14 +2,23 @@
Extra views required for SSO
"""
import logging
from django.conf import settings
from django.http import Http404, HttpResponse, HttpResponseNotAllowed, HttpResponseNotFound, HttpResponseServerError
from django.contrib.auth.decorators import login_required
from django.core.exceptions import ValidationError, PermissionDenied
from django.db import DatabaseError
from django.http import (
Http404, HttpResponse, HttpResponseNotAllowed, HttpResponseNotFound, HttpResponseServerError, JsonResponse
)
from django.shortcuts import redirect, render
from django.urls import reverse
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_http_methods
from django.views.generic.base import View
from edx_django_utils.monitoring import record_exception
from social_core.utils import setting_name
from social_django.models import UserSocialAuth
from social_django.utils import load_backend, load_strategy, psa
from social_django.views import complete
@@ -23,6 +32,8 @@ from .models import SAMLConfiguration, SAMLProviderConfig
URL_NAMESPACE = getattr(settings, setting_name('URL_NAMESPACE'), None) or 'social'
log = logging.getLogger(__name__)
def inactive_user_view(request):
"""
@@ -160,3 +171,100 @@ class IdPRedirectView(View):
return redirect(url)
except ValueError:
return HttpResponseNotFound()
@login_required
@require_http_methods(["POST"])
def disconnect_json_view(request, backend, association_id=None):
"""
Custom disconnect view that returns JSON response instead of redirecting.
See https://github.com/python-social-auth/social-app-django/issues/774 for why this is needed.
"""
user = request.user
# Check URL parameter first, then POST parameter
if not association_id:
association_id = request.POST.get('association_id')
try:
# Load the backend strategy and backend instance
strategy = load_strategy(request)
backend_instance = load_backend(strategy, backend, redirect_uri=request.build_absolute_uri())
# Use backend.disconnect method - simplified approach without partial pipeline
response = backend_instance.disconnect(user=user, association_id=association_id)
# Always return JSON response regardless of what backend.disconnect returns
return JsonResponse({
'success': True,
'message': 'Account successfully disconnected',
'backend': backend,
'association_id': association_id
})
except UserSocialAuth.DoesNotExist:
log.warning(
'Social auth association not found during disconnect: backend=%s, association_id=%s, user_id=%s',
backend, association_id, user.id
)
return JsonResponse({
'success': False,
'error': 'Account not found or already disconnected',
'backend': backend,
'association_id': association_id
}, status=404)
except (ValueError, TypeError) as e:
log.error(
'Invalid parameter during social auth disconnect: backend=%s, association_id=%s, user_id=%s, error=%s',
backend, association_id, user.id, str(e)
)
record_exception()
return JsonResponse({
'success': False,
'error': 'Invalid request parameters',
'backend': backend,
'association_id': association_id
}, status=400)
except DatabaseError as e:
log.error(
'Database error during social auth disconnect: backend=%s, association_id=%s, user_id=%s, error=%s',
backend, association_id, user.id, str(e)
)
record_exception()
return JsonResponse({
'success': False,
'error': 'Service temporarily unavailable',
'backend': backend,
'association_id': association_id
}, status=500)
except ValidationError as e:
log.error(
'Validation error during social auth disconnect: backend=%s, association_id=%s, user_id=%s, error=%s',
backend, association_id, user.id, str(e)
)
record_exception()
return JsonResponse({
'success': False,
'error': 'Invalid request data',
'backend': backend,
'association_id': association_id
}, status=400)
except PermissionDenied as e:
log.warning(
'Permission denied during social auth disconnect: backend=%s, association_id=%s, user_id=%s, error=%s',
backend, association_id, user.id, str(e)
)
record_exception()
return JsonResponse({
'success': False,
'error': 'You do not have permission to perform this action',
'backend': backend,
'association_id': association_id
}, status=403)
except (ImportError, AttributeError, RuntimeError) as e:
log.error(
'System error during social auth disconnect: backend=%s, association_id=%s, user_id=%s, error=%s',
backend, association_id, user.id, str(e)
)
record_exception()
return JsonResponse({
'success': False,
'error': 'Service temporarily unavailable',
'backend': backend,
'association_id': association_id
}, status=500)