diff --git a/lti/views.py b/lti/views.py index c3496b56..f962104d 100644 --- a/lti/views.py +++ b/lti/views.py @@ -13,7 +13,7 @@ Implements the LTI 1.3 / LTI Advantage flow: import logging import traceback import uuid -from urllib.parse import urlencode +from urllib.parse import parse_qs, urlencode, urlparse import jwt from django.http import HttpResponse, HttpResponseRedirect, JsonResponse @@ -233,6 +233,10 @@ class LaunchView(View): else: resource_link_obj = None + # Clear retry counter on successful launch + if 'lti_retry_count' in request.session: + del request.session['lti_retry_count'] + create_lti_session(request, user, message_launch, platform) # Check for media_friendly_token in custom claims @@ -247,8 +251,6 @@ class LaunchView(View): target_link_uri = launch_data.get('https://purl.imsglobal.org/spec/lti/claim/target_link_uri', '') logger.error(f"[LTI LAUNCH DEBUG] target_link_uri: {target_link_uri}") if '?media_token=' in target_link_uri or '&media_token=' in target_link_uri: - from urllib.parse import parse_qs, urlparse - parsed = urlparse(target_link_uri) params = parse_qs(parsed.query) media_token = params.get('media_token', [None])[0] @@ -269,8 +271,18 @@ class LaunchView(View): return HttpResponseRedirect(redirect_url) except LtiException as e: # noqa + error_message = str(e) + + # Special handling for "State not found" errors - attempt retry + if "State not found" in error_message or "state not found" in error_message.lower(): + logger.warning("[LTI LAUNCH] State not found error detected, attempting recovery") + return self.handle_state_not_found(request, platform) + + # Other LTI exceptions - fail normally traceback.print_exc() + except Exception as e: # noqa + error_message = str(e) traceback.print_exc() if platform: @@ -278,6 +290,107 @@ class LaunchView(View): return render(request, 'lti/launch_error.html', {'error': 'LTI Launch Failed', 'message': error_message}, status=400) + def handle_state_not_found(self, request, platform=None): + """ + Handle state not found errors by attempting to restart the OIDC flow. + + This can happen when: + - Cookies are blocked/deleted + - Session expired + - Browser privacy settings interfere + """ + try: + # Check retry count to prevent infinite loops + retry_count = request.session.get('lti_retry_count', 0) + MAX_RETRIES = 2 + + if retry_count >= MAX_RETRIES: + logger.error(f"[LTI RETRY] Max retries ({MAX_RETRIES}) exceeded for state recovery") + return render( + request, + 'lti/launch_error.html', + { + 'error': 'Authentication Failed', + 'message': ( + 'Unable to establish a secure session. This may be due to browser ' + 'cookie settings or privacy features. Please try:\n\n' + '1. Enabling cookies for this site\n' + '2. Disabling tracking protection for this site\n' + '3. Using a different browser\n' + '4. Contacting your administrator if the issue persists' + ), + }, + status=400, + ) + + # Extract launch parameters from the POST request + id_token = request.POST.get('id_token') + state = request.POST.get('state') + + if not id_token: + raise ValueError("No id_token available for retry") + + # Decode JWT to extract issuer and target info (no verification needed for this) + unverified = jwt.decode(id_token, options={"verify_signature": False}) + + iss = unverified.get('iss') + aud = unverified.get('aud') # This is the client_id + target_link_uri = unverified.get('https://purl.imsglobal.org/spec/lti/claim/target_link_uri') + + # Get login_hint from JWT sub claim + login_hint = request.POST.get('login_hint') or unverified.get('sub') + + if not all([iss, aud, target_link_uri]): + raise ValueError("Missing required parameters for OIDC retry") + + # Try to identify platform + if not platform: + try: + platform = LTIPlatform.objects.get(platform_id=iss, client_id=aud) + except LTIPlatform.DoesNotExist: + raise ValueError(f"Platform not found: {iss}/{aud}") + + # Increment retry counter + request.session['lti_retry_count'] = retry_count + 1 + request.session.modified = True + + logger.warning(f"[LTI RETRY] State not found, attempting retry #{retry_count + 1}. " f"Platform: {platform.name}, State: {state}, Target: {target_link_uri}") + + # Build OIDC login URL with all parameters + oidc_login_url = request.build_absolute_uri(reverse('lti:oidc_login')) + + params = { + 'iss': iss, + 'client_id': aud, + 'target_link_uri': target_link_uri, + 'login_hint': login_hint, + } + + # Include lti_message_hint if we have it + lti_message_hint = request.POST.get('lti_message_hint') + if lti_message_hint: + params['lti_message_hint'] = lti_message_hint + + # Add retry indicator + params['retry'] = retry_count + 1 + + redirect_url = f"{oidc_login_url}?{urlencode(params)}" + + logger.error(f"[LTI RETRY] Redirecting to OIDC login: {redirect_url}") + + return HttpResponseRedirect(redirect_url) + + except Exception as retry_error: + logger.error(f"[LTI RETRY] Failed to handle state recovery: {retry_error}") + traceback.print_exc() + + return render( + request, + 'lti/launch_error.html', + {'error': 'LTI Launch Failed', 'message': f'State validation failed and automatic retry was unsuccessful: {str(retry_error)}'}, + status=400, + ) + def sanitize_claims(self, claims): """Remove sensitive data from claims before logging""" safe_claims = claims.copy() diff --git a/templates/lti/launch_error.html b/templates/lti/launch_error.html index f9c689b5..2c4ccab2 100644 --- a/templates/lti/launch_error.html +++ b/templates/lti/launch_error.html @@ -39,6 +39,26 @@ font-size: 14px; margin-top: 20px; } + .troubleshooting { + background: #e3f2fd; + border-left: 4px solid #1976d2; + padding: 15px; + margin: 20px 0; + border-radius: 4px; + } + .troubleshooting h3 { + color: #1976d2; + margin-top: 0; + font-size: 16px; + } + .troubleshooting ol { + margin: 10px 0; + padding-left: 20px; + } + .troubleshooting li { + margin: 8px 0; + line-height: 1.5; + }
@@ -47,11 +67,27 @@If this problem persists, please contact your system administrator.
-You may close this window and try again.
+You may close this window and return to your course to try again.