From 8ac1425a3c5c300e1c03503fb04e406ea7f1c07f Mon Sep 17 00:00:00 2001 From: Jesse Stewart Date: Thu, 31 Jul 2025 16:02:50 -0400 Subject: [PATCH 01/16] feat: adds custom endpoint for TPA disconnect --- .../custom_disconnect_view.py | 62 +++++++++++++++++++ .../djangoapps/third_party_auth/pipeline.py | 5 +- common/djangoapps/third_party_auth/urls.py | 4 ++ 3 files changed, 69 insertions(+), 2 deletions(-) create mode 100644 common/djangoapps/third_party_auth/custom_disconnect_view.py diff --git a/common/djangoapps/third_party_auth/custom_disconnect_view.py b/common/djangoapps/third_party_auth/custom_disconnect_view.py new file mode 100644 index 000000000000..e853a7e93962 --- /dev/null +++ b/common/djangoapps/third_party_auth/custom_disconnect_view.py @@ -0,0 +1,62 @@ +""" +Custom disconnect view that returns JSON instead of redirecting to avoid CORS issues. +""" +from django.http import JsonResponse +from django.contrib.auth.decorators import login_required +from django.views.decorators.http import require_http_methods +from social_django.utils import psa +from social_django.models import UserSocialAuth + + +@login_required +@require_http_methods(["POST"]) +@psa() +def disconnect_json_view(request, backend, association_id=None): + """ + Custom disconnect view that returns JSON response instead of redirecting. + This prevents CORS issues when called from MFE frontends. + """ + user = request.user + + # Check URL parameter first, then query parameter for backward compatibility + if not association_id: + association_id = request.GET.get('association_id') + + try: + if association_id: + # Disconnect specific association by ID + association = UserSocialAuth.objects.get( + id=association_id, + user=user, + provider=backend + ) + association.delete() + else: + # Disconnect all associations for this backend + UserSocialAuth.objects.filter( + user=user, + provider=backend + ).delete() + + return JsonResponse({ + 'success': True, + 'message': 'Account successfully disconnected', + 'backend': backend, + 'association_id': association_id + }) + + except UserSocialAuth.DoesNotExist: + return JsonResponse({ + 'success': False, + 'error': 'Social auth association not found', + 'backend': backend, + 'association_id': association_id + }, status=404) + + except Exception as e: + return JsonResponse({ + 'success': False, + 'error': str(e), + 'backend': backend, + 'association_id': association_id + }, status=500) \ No newline at end of file diff --git a/common/djangoapps/third_party_auth/pipeline.py b/common/djangoapps/third_party_auth/pipeline.py index ef1e6f887c36..a22457b3145e 100644 --- a/common/djangoapps/third_party_auth/pipeline.py +++ b/common/djangoapps/third_party_auth/pipeline.py @@ -379,10 +379,11 @@ 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/urls.py b/common/djangoapps/third_party_auth/urls.py index e0804596bf57..7f8ee7c15f00 100644 --- a/common/djangoapps/third_party_auth/urls.py +++ b/common/djangoapps/third_party_auth/urls.py @@ -10,6 +10,7 @@ post_to_custom_auth_form, saml_metadata_view ) +from .custom_disconnect_view import disconnect_json_view urlpatterns = [ path('auth/inactive', inactive_user_view, name="third_party_inactive_redirect"), @@ -17,6 +18,9 @@ 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')), From 991c7749aa8d5fcb895d07ef15d4faa85a2d2e67 Mon Sep 17 00:00:00 2001 From: Jesse Stewart Date: Thu, 31 Jul 2025 16:21:36 -0400 Subject: [PATCH 02/16] feat: address feedback --- .../custom_disconnect_view.py | 48 +++++++++++++++++-- 1 file changed, 45 insertions(+), 3 deletions(-) diff --git a/common/djangoapps/third_party_auth/custom_disconnect_view.py b/common/djangoapps/third_party_auth/custom_disconnect_view.py index e853a7e93962..ed72539e4983 100644 --- a/common/djangoapps/third_party_auth/custom_disconnect_view.py +++ b/common/djangoapps/third_party_auth/custom_disconnect_view.py @@ -4,6 +4,8 @@ from django.http import JsonResponse from django.contrib.auth.decorators import login_required from django.views.decorators.http import require_http_methods +from django.core.exceptions import ValidationError, PermissionDenied +from django.db import DatabaseError from social_django.utils import psa from social_django.models import UserSocialAuth @@ -18,9 +20,9 @@ def disconnect_json_view(request, backend, association_id=None): """ user = request.user - # Check URL parameter first, then query parameter for backward compatibility + # Check URL parameter first, then POST parameter, and fallback to GET parameter for backward compatibility if not association_id: - association_id = request.GET.get('association_id') + association_id = request.POST.get('association_id') or request.GET.get('association_id') try: if association_id: @@ -53,10 +55,50 @@ def disconnect_json_view(request, backend, association_id=None): 'association_id': association_id }, status=404) + except ValueError as e: + return JsonResponse({ + 'success': False, + 'error': 'Invalid parameter value provided', + 'backend': backend, + 'association_id': association_id + }, status=400) + + except TypeError as e: + return JsonResponse({ + 'success': False, + 'error': 'Invalid parameter type provided', + 'backend': backend, + 'association_id': association_id + }, status=400) + + except DatabaseError as e: + return JsonResponse({ + 'success': False, + 'error': 'Database operation failed', + 'backend': backend, + 'association_id': association_id + }, status=500) + + except ValidationError as e: + return JsonResponse({ + 'success': False, + 'error': 'Validation failed', + 'backend': backend, + 'association_id': association_id + }, status=400) + + except PermissionDenied as e: + return JsonResponse({ + 'success': False, + 'error': 'Permission denied', + 'backend': backend, + 'association_id': association_id + }, status=403) + except Exception as e: return JsonResponse({ 'success': False, - 'error': str(e), + 'error': 'An unexpected error occurred', 'backend': backend, 'association_id': association_id }, status=500) \ No newline at end of file From 445468cbe343e05bedf0e059b39568819afc0f14 Mon Sep 17 00:00:00 2001 From: Jesse Stewart Date: Thu, 31 Jul 2025 16:47:39 -0400 Subject: [PATCH 03/16] feat: remove GET from endpoint --- common/djangoapps/third_party_auth/custom_disconnect_view.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/common/djangoapps/third_party_auth/custom_disconnect_view.py b/common/djangoapps/third_party_auth/custom_disconnect_view.py index ed72539e4983..8f180d4972ca 100644 --- a/common/djangoapps/third_party_auth/custom_disconnect_view.py +++ b/common/djangoapps/third_party_auth/custom_disconnect_view.py @@ -22,7 +22,7 @@ def disconnect_json_view(request, backend, association_id=None): # Check URL parameter first, then POST parameter, and fallback to GET parameter for backward compatibility if not association_id: - association_id = request.POST.get('association_id') or request.GET.get('association_id') + association_id = request.POST.get('association_id') try: if association_id: From 65c4d22935d4520eebdce67bc5115b88c9fd3131 Mon Sep 17 00:00:00 2001 From: Jesse Stewart Date: Thu, 31 Jul 2025 17:11:12 -0400 Subject: [PATCH 04/16] fix: lint files --- .../custom_disconnect_view.py | 50 +++++++------------ 1 file changed, 18 insertions(+), 32 deletions(-) diff --git a/common/djangoapps/third_party_auth/custom_disconnect_view.py b/common/djangoapps/third_party_auth/custom_disconnect_view.py index 8f180d4972ca..1fcbe6d2483c 100644 --- a/common/djangoapps/third_party_auth/custom_disconnect_view.py +++ b/common/djangoapps/third_party_auth/custom_disconnect_view.py @@ -19,11 +19,11 @@ def disconnect_json_view(request, backend, association_id=None): This prevents CORS issues when called from MFE frontends. """ user = request.user - - # Check URL parameter first, then POST parameter, and fallback to GET parameter for backward compatibility + # Check URL parameter first, then POST parameter, and fallback to GET parameter + # for backward compatibility if not association_id: association_id = request.POST.get('association_id') - + error_response = None try: if association_id: # Disconnect specific association by ID @@ -39,66 +39,52 @@ def disconnect_json_view(request, backend, association_id=None): user=user, provider=backend ).delete() - return JsonResponse({ 'success': True, 'message': 'Account successfully disconnected', 'backend': backend, 'association_id': association_id }) - except UserSocialAuth.DoesNotExist: - return JsonResponse({ + error_response = JsonResponse({ 'success': False, 'error': 'Social auth association not found', 'backend': backend, 'association_id': association_id }, status=404) - - except ValueError as e: - return JsonResponse({ - 'success': False, - 'error': 'Invalid parameter value provided', - 'backend': backend, - 'association_id': association_id - }, status=400) - - except TypeError as e: - return JsonResponse({ + except (ValueError, TypeError): + error_response = JsonResponse({ 'success': False, - 'error': 'Invalid parameter type provided', + 'error': 'Invalid parameter provided', 'backend': backend, 'association_id': association_id }, status=400) - - except DatabaseError as e: - return JsonResponse({ + except DatabaseError: + error_response = JsonResponse({ 'success': False, 'error': 'Database operation failed', 'backend': backend, 'association_id': association_id }, status=500) - - except ValidationError as e: - return JsonResponse({ + except ValidationError: + error_response = JsonResponse({ 'success': False, 'error': 'Validation failed', 'backend': backend, 'association_id': association_id }, status=400) - - except PermissionDenied as e: - return JsonResponse({ + except PermissionDenied: + error_response = JsonResponse({ 'success': False, 'error': 'Permission denied', 'backend': backend, 'association_id': association_id }, status=403) - - except Exception as e: - return JsonResponse({ + except (OSError, IOError): + error_response = JsonResponse({ 'success': False, - 'error': 'An unexpected error occurred', + 'error': 'System error occurred', 'backend': backend, 'association_id': association_id - }, status=500) \ No newline at end of file + }, status=500) + return error_response From 093c5623592d67064e6dd72ba079892448909c8a Mon Sep 17 00:00:00 2001 From: Jesse Stewart Date: Mon, 4 Aug 2025 11:24:25 -0400 Subject: [PATCH 05/16] fix: lint errors --- common/djangoapps/third_party_auth/pipeline.py | 4 +++- common/djangoapps/third_party_auth/urls.py | 6 +++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/common/djangoapps/third_party_auth/pipeline.py b/common/djangoapps/third_party_auth/pipeline.py index 436b9ed4786a..496cfce93c1f 100644 --- a/common/djangoapps/third_party_auth/pipeline.py +++ b/common/djangoapps/third_party_auth/pipeline.py @@ -381,7 +381,9 @@ def get_disconnect_url(provider_id, association_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('custom_disconnect_json_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('custom_disconnect_json', backend_name) diff --git a/common/djangoapps/third_party_auth/urls.py b/common/djangoapps/third_party_auth/urls.py index 7f8ee7c15f00..078d2a9a0648 100644 --- a/common/djangoapps/third_party_auth/urls.py +++ b/common/djangoapps/third_party_auth/urls.py @@ -20,7 +20,11 @@ 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'), + 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')), From 01a736309e7eeb0b4af37aeda677ccfff8c3c3f9 Mon Sep 17 00:00:00 2001 From: Jesse Stewart Date: Mon, 4 Aug 2025 11:53:18 -0400 Subject: [PATCH 06/16] fix: update tests --- common/djangoapps/third_party_auth/api/tests/test_views.py | 2 +- .../third_party_auth/tests/test_pipeline_integration.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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/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): From 43478e149ad6bcc85ebfdcf51f0f94beaacbbbfe Mon Sep 17 00:00:00 2001 From: Jesse Stewart Date: Mon, 4 Aug 2025 12:42:35 -0400 Subject: [PATCH 07/16] fix: address feedback --- .../third_party_auth/custom_disconnect_view.py | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/common/djangoapps/third_party_auth/custom_disconnect_view.py b/common/djangoapps/third_party_auth/custom_disconnect_view.py index 1fcbe6d2483c..76a3bb1f078e 100644 --- a/common/djangoapps/third_party_auth/custom_disconnect_view.py +++ b/common/djangoapps/third_party_auth/custom_disconnect_view.py @@ -19,11 +19,10 @@ def disconnect_json_view(request, backend, association_id=None): This prevents CORS issues when called from MFE frontends. """ user = request.user - # Check URL parameter first, then POST parameter, and fallback to GET parameter + # Check URL parameter first, then POST parameter # for backward compatibility if not association_id: association_id = request.POST.get('association_id') - error_response = None try: if association_id: # Disconnect specific association by ID @@ -55,7 +54,7 @@ def disconnect_json_view(request, backend, association_id=None): except (ValueError, TypeError): error_response = JsonResponse({ 'success': False, - 'error': 'Invalid parameter provided', + 'error': 'Invalid association_id parameter', 'backend': backend, 'association_id': association_id }, status=400) @@ -80,11 +79,4 @@ def disconnect_json_view(request, backend, association_id=None): 'backend': backend, 'association_id': association_id }, status=403) - except (OSError, IOError): - error_response = JsonResponse({ - 'success': False, - 'error': 'System error occurred', - 'backend': backend, - 'association_id': association_id - }, status=500) return error_response From fd4d76060c15390cf576c5c2f1fbc80833a725a9 Mon Sep 17 00:00:00 2001 From: Jesse Stewart Date: Mon, 4 Aug 2025 13:13:17 -0400 Subject: [PATCH 08/16] fix: remove comment --- common/djangoapps/third_party_auth/custom_disconnect_view.py | 1 - 1 file changed, 1 deletion(-) diff --git a/common/djangoapps/third_party_auth/custom_disconnect_view.py b/common/djangoapps/third_party_auth/custom_disconnect_view.py index 76a3bb1f078e..d9223ab10fb8 100644 --- a/common/djangoapps/third_party_auth/custom_disconnect_view.py +++ b/common/djangoapps/third_party_auth/custom_disconnect_view.py @@ -20,7 +20,6 @@ def disconnect_json_view(request, backend, association_id=None): """ user = request.user # Check URL parameter first, then POST parameter - # for backward compatibility if not association_id: association_id = request.POST.get('association_id') try: From b0f41bcef2a3a91d5c36a6cf27ff69d01c5bfc9e Mon Sep 17 00:00:00 2001 From: Jesse Stewart Date: Tue, 5 Aug 2025 16:00:57 -0400 Subject: [PATCH 09/16] feat: removes comment --- common/djangoapps/third_party_auth/custom_disconnect_view.py | 1 - 1 file changed, 1 deletion(-) diff --git a/common/djangoapps/third_party_auth/custom_disconnect_view.py b/common/djangoapps/third_party_auth/custom_disconnect_view.py index d9223ab10fb8..683acb532ab5 100644 --- a/common/djangoapps/third_party_auth/custom_disconnect_view.py +++ b/common/djangoapps/third_party_auth/custom_disconnect_view.py @@ -16,7 +16,6 @@ def disconnect_json_view(request, backend, association_id=None): """ Custom disconnect view that returns JSON response instead of redirecting. - This prevents CORS issues when called from MFE frontends. """ user = request.user # Check URL parameter first, then POST parameter From 56bbcbe28fedc99f077a9886a14a6da65b78d752 Mon Sep 17 00:00:00 2001 From: Jesse Stewart Date: Thu, 7 Aug 2025 15:16:15 -0400 Subject: [PATCH 10/16] feat: adds backend_instance --- .../custom_disconnect_view.py | 45 ++++++++++--------- 1 file changed, 23 insertions(+), 22 deletions(-) diff --git a/common/djangoapps/third_party_auth/custom_disconnect_view.py b/common/djangoapps/third_party_auth/custom_disconnect_view.py index 683acb532ab5..a52c3e744979 100644 --- a/common/djangoapps/third_party_auth/custom_disconnect_view.py +++ b/common/djangoapps/third_party_auth/custom_disconnect_view.py @@ -6,13 +6,12 @@ from django.views.decorators.http import require_http_methods from django.core.exceptions import ValidationError, PermissionDenied from django.db import DatabaseError -from social_django.utils import psa +from social_django.utils import load_strategy, load_backend from social_django.models import UserSocialAuth @login_required @require_http_methods(["POST"]) -@psa() def disconnect_json_view(request, backend, association_id=None): """ Custom disconnect view that returns JSON response instead of redirecting. @@ -21,60 +20,62 @@ def disconnect_json_view(request, backend, association_id=None): # Check URL parameter first, then POST parameter if not association_id: association_id = request.POST.get('association_id') + try: - if association_id: - # Disconnect specific association by ID - association = UserSocialAuth.objects.get( - id=association_id, - user=user, - provider=backend - ) - association.delete() - else: - # Disconnect all associations for this backend - UserSocialAuth.objects.filter( - user=user, - provider=backend - ).delete() + # 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: - error_response = JsonResponse({ + return JsonResponse({ 'success': False, 'error': 'Social auth association not found', 'backend': backend, 'association_id': association_id }, status=404) except (ValueError, TypeError): - error_response = JsonResponse({ + return JsonResponse({ 'success': False, 'error': 'Invalid association_id parameter', 'backend': backend, 'association_id': association_id }, status=400) except DatabaseError: - error_response = JsonResponse({ + return JsonResponse({ 'success': False, 'error': 'Database operation failed', 'backend': backend, 'association_id': association_id }, status=500) except ValidationError: - error_response = JsonResponse({ + return JsonResponse({ 'success': False, 'error': 'Validation failed', 'backend': backend, 'association_id': association_id }, status=400) except PermissionDenied: - error_response = JsonResponse({ + return JsonResponse({ 'success': False, 'error': 'Permission denied', 'backend': backend, 'association_id': association_id }, status=403) - return error_response + except Exception as e: + return JsonResponse({ + 'success': False, + 'error': f'Disconnect failed: {str(e)}', + 'backend': backend, + 'association_id': association_id + }, status=500) From eb8dde0499dc04527085f3649a587418efa04500 Mon Sep 17 00:00:00 2001 From: Jesse Stewart Date: Thu, 7 Aug 2025 15:26:39 -0400 Subject: [PATCH 11/16] fix: lint --- .../djangoapps/third_party_auth/custom_disconnect_view.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/common/djangoapps/third_party_auth/custom_disconnect_view.py b/common/djangoapps/third_party_auth/custom_disconnect_view.py index a52c3e744979..68812bb99587 100644 --- a/common/djangoapps/third_party_auth/custom_disconnect_view.py +++ b/common/djangoapps/third_party_auth/custom_disconnect_view.py @@ -20,15 +20,12 @@ def disconnect_json_view(request, backend, association_id=None): # 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, @@ -36,7 +33,6 @@ def disconnect_json_view(request, backend, association_id=None): 'backend': backend, 'association_id': association_id }) - except UserSocialAuth.DoesNotExist: return JsonResponse({ 'success': False, @@ -72,7 +68,7 @@ def disconnect_json_view(request, backend, association_id=None): 'backend': backend, 'association_id': association_id }, status=403) - except Exception as e: + except (ImportError, AttributeError, RuntimeError) as e: return JsonResponse({ 'success': False, 'error': f'Disconnect failed: {str(e)}', From 7ed0f070affbd4e4382e3af75e3669009075216e Mon Sep 17 00:00:00 2001 From: Jesse Stewart Date: Thu, 7 Aug 2025 18:36:35 -0400 Subject: [PATCH 12/16] feat: pr feedback --- .../custom_disconnect_view.py | 77 ------------------- common/djangoapps/third_party_auth/urls.py | 2 +- common/djangoapps/third_party_auth/views.py | 74 +++++++++++++++++- 3 files changed, 74 insertions(+), 79 deletions(-) delete mode 100644 common/djangoapps/third_party_auth/custom_disconnect_view.py diff --git a/common/djangoapps/third_party_auth/custom_disconnect_view.py b/common/djangoapps/third_party_auth/custom_disconnect_view.py deleted file mode 100644 index 68812bb99587..000000000000 --- a/common/djangoapps/third_party_auth/custom_disconnect_view.py +++ /dev/null @@ -1,77 +0,0 @@ -""" -Custom disconnect view that returns JSON instead of redirecting to avoid CORS issues. -""" -from django.http import JsonResponse -from django.contrib.auth.decorators import login_required -from django.views.decorators.http import require_http_methods -from django.core.exceptions import ValidationError, PermissionDenied -from django.db import DatabaseError -from social_django.utils import load_strategy, load_backend -from social_django.models import UserSocialAuth - - -@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. - """ - 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: - return JsonResponse({ - 'success': False, - 'error': 'Social auth association not found', - 'backend': backend, - 'association_id': association_id - }, status=404) - except (ValueError, TypeError): - return JsonResponse({ - 'success': False, - 'error': 'Invalid association_id parameter', - 'backend': backend, - 'association_id': association_id - }, status=400) - except DatabaseError: - return JsonResponse({ - 'success': False, - 'error': 'Database operation failed', - 'backend': backend, - 'association_id': association_id - }, status=500) - except ValidationError: - return JsonResponse({ - 'success': False, - 'error': 'Validation failed', - 'backend': backend, - 'association_id': association_id - }, status=400) - except PermissionDenied: - return JsonResponse({ - 'success': False, - 'error': 'Permission denied', - 'backend': backend, - 'association_id': association_id - }, status=403) - except (ImportError, AttributeError, RuntimeError) as e: - return JsonResponse({ - 'success': False, - 'error': f'Disconnect failed: {str(e)}', - 'backend': backend, - 'association_id': association_id - }, status=500) diff --git a/common/djangoapps/third_party_auth/urls.py b/common/djangoapps/third_party_auth/urls.py index 078d2a9a0648..a5908da26fd0 100644 --- a/common/djangoapps/third_party_auth/urls.py +++ b/common/djangoapps/third_party_auth/urls.py @@ -5,12 +5,12 @@ from .views import ( IdPRedirectView, + disconnect_json_view, inactive_user_view, lti_login_and_complete_view, post_to_custom_auth_form, saml_metadata_view ) -from .custom_disconnect_view import disconnect_json_view urlpatterns = [ path('auth/inactive', inactive_user_view, name="third_party_inactive_redirect"), diff --git a/common/djangoapps/third_party_auth/views.py b/common/djangoapps/third_party_auth/views.py index d24ba8cfd7db..6757d0f78ac0 100644 --- a/common/djangoapps/third_party_auth/views.py +++ b/common/djangoapps/third_party_auth/views.py @@ -4,12 +4,17 @@ 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 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 @@ -160,3 +165,70 @@ 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. + """ + 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: + return JsonResponse({ + 'success': False, + 'error': 'Social auth association not found', + 'backend': backend, + 'association_id': association_id + }, status=404) + except (ValueError, TypeError): + return JsonResponse({ + 'success': False, + 'error': 'Invalid association_id parameter', + 'backend': backend, + 'association_id': association_id + }, status=400) + except DatabaseError: + return JsonResponse({ + 'success': False, + 'error': 'Database operation failed', + 'backend': backend, + 'association_id': association_id + }, status=500) + except ValidationError: + return JsonResponse({ + 'success': False, + 'error': 'Validation failed', + 'backend': backend, + 'association_id': association_id + }, status=400) + except PermissionDenied: + return JsonResponse({ + 'success': False, + 'error': 'Permission denied', + 'backend': backend, + 'association_id': association_id + }, status=403) + except (ImportError, AttributeError, RuntimeError) as e: + return JsonResponse({ + 'success': False, + 'error': f'Disconnect failed: {str(e)}', + 'backend': backend, + 'association_id': association_id + }, status=500) From bb5c61e7698911477936a735b328c057a7722c63 Mon Sep 17 00:00:00 2001 From: Jesse Stewart Date: Fri, 8 Aug 2025 13:52:29 -0400 Subject: [PATCH 13/16] fix: linting --- common/djangoapps/third_party_auth/views.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/common/djangoapps/third_party_auth/views.py b/common/djangoapps/third_party_auth/views.py index 6757d0f78ac0..5e5073ec4b13 100644 --- a/common/djangoapps/third_party_auth/views.py +++ b/common/djangoapps/third_party_auth/views.py @@ -7,7 +7,9 @@ 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.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 df037f5ad5feef8969beb3c30e01d4da9da15c75 Mon Sep 17 00:00:00 2001 From: Jesse Stewart Date: Fri, 29 Aug 2025 10:43:23 -0400 Subject: [PATCH 14/16] feat: add logging to exception and return generic error --- common/djangoapps/third_party_auth/views.py | 53 +++++++++++++++++---- 1 file changed, 43 insertions(+), 10 deletions(-) diff --git a/common/djangoapps/third_party_auth/views.py b/common/djangoapps/third_party_auth/views.py index 5e5073ec4b13..d534a75b52d7 100644 --- a/common/djangoapps/third_party_auth/views.py +++ b/common/djangoapps/third_party_auth/views.py @@ -2,6 +2,7 @@ Extra views required for SSO """ +import logging from django.conf import settings from django.contrib.auth.decorators import login_required @@ -15,6 +16,7 @@ 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 @@ -30,6 +32,8 @@ URL_NAMESPACE = getattr(settings, setting_name('URL_NAMESPACE'), None) or 'social' +log = logging.getLogger(__name__) + def inactive_user_view(request): """ @@ -193,44 +197,73 @@ def disconnect_json_view(request, backend, association_id=None): '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': 'Social auth association not found', + 'error': 'Account not found or already disconnected', 'backend': backend, 'association_id': association_id }, status=404) - except (ValueError, TypeError): + 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 association_id parameter', + 'error': 'Invalid request parameters', 'backend': backend, 'association_id': association_id }, status=400) - except DatabaseError: + 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': 'Database operation failed', + 'error': 'Service temporarily unavailable', 'backend': backend, 'association_id': association_id }, status=500) - except ValidationError: + 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': 'Validation failed', + 'error': 'Invalid request data', 'backend': backend, 'association_id': association_id }, status=400) - except PermissionDenied: + 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': 'Permission denied', + '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': f'Disconnect failed: {str(e)}', + 'error': 'Service temporarily unavailable', 'backend': backend, 'association_id': association_id }, status=500) From f847c19968a716386b4f815d62cf7d97dd1cf58d Mon Sep 17 00:00:00 2001 From: wgu-jesse-stewart Date: Fri, 12 Sep 2025 11:33:51 -0400 Subject: [PATCH 15/16] Update common/djangoapps/third_party_auth/views.py Co-authored-by: Feanil Patel --- common/djangoapps/third_party_auth/views.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/common/djangoapps/third_party_auth/views.py b/common/djangoapps/third_party_auth/views.py index d534a75b52d7..33093e11eadd 100644 --- a/common/djangoapps/third_party_auth/views.py +++ b/common/djangoapps/third_party_auth/views.py @@ -178,6 +178,8 @@ def get(self, request, *args, **kwargs): # lint-amnesty, pylint: disable=unused 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 From 498b5d72dd7bafbc4d5d4fc553b4c4e253bfa8e3 Mon Sep 17 00:00:00 2001 From: wgu-jesse-stewart Date: Fri, 12 Sep 2025 11:44:46 -0400 Subject: [PATCH 16/16] Update common/djangoapps/third_party_auth/views.py Co-authored-by: Feanil Patel --- common/djangoapps/third_party_auth/views.py | 1 - 1 file changed, 1 deletion(-) diff --git a/common/djangoapps/third_party_auth/views.py b/common/djangoapps/third_party_auth/views.py index 33093e11eadd..2153354cbf9e 100644 --- a/common/djangoapps/third_party_auth/views.py +++ b/common/djangoapps/third_party_auth/views.py @@ -178,7 +178,6 @@ def get(self, request, *args, **kwargs): # lint-amnesty, pylint: disable=unused 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