diff --git a/common/djangoapps/third_party_auth/api/tests/test_views.py b/common/djangoapps/third_party_auth/api/tests/test_views.py index 6ff644f48eda..f7834001d66b 100644 --- a/common/djangoapps/third_party_auth/api/tests/test_views.py +++ b/common/djangoapps/third_party_auth/api/tests/test_views.py @@ -419,7 +419,7 @@ def test_get(self): 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' }]) diff --git a/common/djangoapps/third_party_auth/pipeline.py b/common/djangoapps/third_party_auth/pipeline.py index 2fff57620a9e..496cfce93c1f 100644 --- a/common/djangoapps/third_party_auth/pipeline.py +++ b/common/djangoapps/third_party_auth/pipeline.py @@ -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): diff --git a/common/djangoapps/third_party_auth/tests/test_pipeline_integration.py b/common/djangoapps/third_party_auth/tests/test_pipeline_integration.py index c19b0b8d96aa..0d0a2cf7241d 100644 --- a/common/djangoapps/third_party_auth/tests/test_pipeline_integration.py +++ b/common/djangoapps/third_party_auth/tests/test_pipeline_integration.py @@ -176,7 +176,7 @@ def test_disconnect_url_raises_value_error_if_provider_not_enabled(self): 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): diff --git a/common/djangoapps/third_party_auth/urls.py b/common/djangoapps/third_party_auth/urls.py index e0804596bf57..a5908da26fd0 100644 --- a/common/djangoapps/third_party_auth/urls.py +++ b/common/djangoapps/third_party_auth/urls.py @@ -5,6 +5,7 @@ from .views import ( IdPRedirectView, + disconnect_json_view, inactive_user_view, lti_login_and_complete_view, post_to_custom_auth_form, @@ -17,6 +18,13 @@ re_path(r'^auth/saml/metadata.xml', saml_metadata_view), re_path(r'^auth/login/(?Plti)/$', lti_login_and_complete_view), path('auth/idp_redirect/', IdPRedirectView.as_view(), name="idp_redirect"), + # Custom JSON disconnect endpoint to avoid CORS issues + re_path(r'^auth/disconnect_json/(?P[^/]+)/$', disconnect_json_view, name='custom_disconnect_json'), + re_path( + r'^auth/disconnect_json/(?P[^/]+)/(?P\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')), diff --git a/common/djangoapps/third_party_auth/views.py b/common/djangoapps/third_party_auth/views.py index d24ba8cfd7db..2153354cbf9e 100644 --- a/common/djangoapps/third_party_auth/views.py +++ b/common/djangoapps/third_party_auth/views.py @@ -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 @@ URL_NAMESPACE = getattr(settings, setting_name('URL_NAMESPACE'), None) or 'social' +log = logging.getLogger(__name__) + def inactive_user_view(request): """ @@ -160,3 +171,100 @@ def get(self, request, *args, **kwargs): # lint-amnesty, pylint: disable=unused 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)