Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
8ac1425
feat: adds custom endpoint for TPA disconnect
wgu-jesse-stewart Jul 31, 2025
a5cbb37
Merge branch 'openedx:master' into fix-disconnect-cors
wgu-jesse-stewart Jul 31, 2025
991c774
feat: address feedback
wgu-jesse-stewart Jul 31, 2025
a0f0e05
Merge branch 'fix-disconnect-cors' of https://github.com/WGU-Open-edX…
wgu-jesse-stewart Jul 31, 2025
445468c
feat: remove GET from endpoint
wgu-jesse-stewart Jul 31, 2025
65c4d22
fix: lint files
wgu-jesse-stewart Jul 31, 2025
093c562
fix: lint errors
wgu-jesse-stewart Aug 4, 2025
01a7363
fix: update tests
wgu-jesse-stewart Aug 4, 2025
43478e1
fix: address feedback
wgu-jesse-stewart Aug 4, 2025
fd4d760
fix: remove comment
wgu-jesse-stewart Aug 4, 2025
835cc24
Merge branch 'master' into fix-disconnect-cors
wgu-jesse-stewart Aug 4, 2025
ba17be8
Merge branch 'master' into fix-disconnect-cors
wgu-jesse-stewart Aug 5, 2025
b0f41bc
feat: removes comment
wgu-jesse-stewart Aug 5, 2025
56bbcbe
feat: adds backend_instance
wgu-jesse-stewart Aug 7, 2025
eb8dde0
fix: lint
wgu-jesse-stewart Aug 7, 2025
eecf1d4
Merge branch 'master' into fix-disconnect-cors
wgu-jesse-stewart Aug 7, 2025
7ed0f07
feat: pr feedback
wgu-jesse-stewart Aug 7, 2025
bb5c61e
fix: linting
wgu-jesse-stewart Aug 8, 2025
f276260
Merge branch 'master' into fix-disconnect-cors
wgu-jesse-stewart Aug 8, 2025
d3af088
Merge branch 'master' into fix-disconnect-cors
wgu-jesse-stewart Aug 11, 2025
da307cf
Merge branch 'master' into fix-disconnect-cors
wgu-jesse-stewart Aug 12, 2025
338ebed
Merge branch 'master' into fix-disconnect-cors
wgu-jesse-stewart Aug 13, 2025
63dfe98
Merge branch 'master' into fix-disconnect-cors
wgu-jesse-stewart Aug 14, 2025
ac89165
Merge branch 'master' into fix-disconnect-cors
wgu-jesse-stewart Aug 14, 2025
6f65452
Merge branch 'master' into fix-disconnect-cors
wgu-jesse-stewart Aug 18, 2025
0e115db
Merge branch 'master' into fix-disconnect-cors
wgu-jesse-stewart Aug 19, 2025
cc14612
Merge branch 'master' into fix-disconnect-cors
wgu-jesse-stewart Aug 20, 2025
39b254f
Merge branch 'master' into fix-disconnect-cors
wgu-jesse-stewart Aug 26, 2025
df037f5
feat: add logging to exception and return generic error
wgu-jesse-stewart Aug 29, 2025
6c9c748
Merge branch 'master' into fix-disconnect-cors
wgu-jesse-stewart Sep 11, 2025
f847c19
Update common/djangoapps/third_party_auth/views.py
wgu-jesse-stewart Sep 12, 2025
498b5d7
Update common/djangoapps/third_party_auth/views.py
wgu-jesse-stewart Sep 12, 2025
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
2 changes: 1 addition & 1 deletion common/djangoapps/third_party_auth/api/tests/test_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'
}])
7 changes: 5 additions & 2 deletions common/djangoapps/third_party_auth/pipeline.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
8 changes: 8 additions & 0 deletions common/djangoapps/third_party_auth/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

from .views import (
IdPRedirectView,
disconnect_json_view,
inactive_user_view,
lti_login_and_complete_view,
post_to_custom_auth_form,
Expand All @@ -17,6 +18,13 @@
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')),
Expand Down
110 changes: 109 additions & 1 deletion common/djangoapps/third_party_auth/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -23,6 +32,8 @@

URL_NAMESPACE = getattr(settings, setting_name('URL_NAMESPACE'), None) or 'social'

log = logging.getLogger(__name__)


def inactive_user_view(request):
"""
Expand Down Expand Up @@ -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)
Loading