diff --git a/lms/routes.py b/lms/routes.py index 0d3e549774..0ad60f7423 100644 --- a/lms/routes.py +++ b/lms/routes.py @@ -148,6 +148,10 @@ def includeme(config): # pylint:disable=too-many-statements config.add_route( "canvas_studio_api.oauth.refresh", "/api/canvas_studio/oauth/refresh" ) + config.add_route( + "canvas_studio_api.oauth.refresh_admin", + "/api/canvas_studio/oauth/refresh_admin", + ) config.add_route("canvas_studio_api.media.list", "/api/canvas_studio/media") config.add_route( "canvas_studio_api.collections.media.list", diff --git a/lms/services/canvas_studio.py b/lms/services/canvas_studio.py index 1c9049481b..f0c8f4b1cd 100644 --- a/lms/services/canvas_studio.py +++ b/lms/services/canvas_studio.py @@ -10,7 +10,11 @@ from lms.models.oauth2_token import Service from lms.models.user import User from lms.services.aes import AESService -from lms.services.exceptions import ExternalRequestError, OAuth2TokenError +from lms.services.exceptions import ( + ExternalRequestError, + OAuth2TokenError, + SerializableError, +) from lms.services.oauth_http import OAuthHTTPService from lms.services.oauth_http import factory as oauth_http_factory from lms.validation._base import RequestsResponseSchema @@ -150,6 +154,21 @@ def refresh_access_token(self): auth=(self._client_id, self._client_secret), ) + def refresh_admin_access_token(self): + """Refresh the existing admin access token for Canvas Studio API calls.""" + + try: + self._admin_oauth_http.refresh_access_token( + self._token_url(), + self.redirect_uri(), + auth=(self._client_id, self._client_secret), + ) + except ExternalRequestError as refresh_err: + raise SerializableError( + error_code="canvas_studio_admin_token_refresh_failed", + message="Canvas Studio admin token refresh failed.", + ) from refresh_err + def authorization_url(self, state: str) -> str: """ Construct the authorization endpoint URL for Canvas Studio. @@ -341,9 +360,7 @@ def _api_request( response = self._bare_api_request(path, as_admin=as_admin) return schema_cls(response).parse() - def _admin_api_request( - self, path: str, allow_redirects=True, allow_refresh=True - ) -> requests.Response: + def _admin_api_request(self, path: str, allow_redirects=True) -> requests.Response: """ Make a request to the Canvas Studio API using the admin user identity. @@ -374,36 +391,11 @@ def _admin_api_request( ) from err raise - # If we already performed a refresh in response to the original - # request, `allow_refresh` will be False and we abort. - if not allow_refresh: - raise - - # For admin requests, we have to do the refresh here, because the - # refresh path involving the frontend is designed for refreshing - # the current LTI user's access token. - # - # Ideally it would be simpler if we could just encapsulate the - # entire refresh process in OAuthHTTPService. - try: - self._admin_oauth_http.refresh_access_token( - self._token_url(), - self.redirect_uri(), - auth=(self._client_id, self._client_secret), - ) - except ExternalRequestError as refresh_err: - raise ExternalRequestError( - message="Canvas Studio admin token refresh failed. Ask the admin user to re-authenticate." - ) from refresh_err - - # Retry the request with the new token. - return self._admin_api_request( - path, - allow_redirects=allow_redirects, - # If the request fails again, make sure we don't repeat the - # refresh to avoid getting stuck in a loop. - allow_refresh=False, - ) + raise OAuth2TokenError( + refreshable=True, + refresh_route="canvas_studio_api.oauth.refresh_admin", + refresh_service=Service.CANVAS_STUDIO, + ) from err def _bare_api_request( self, path: str, as_admin=False, allow_redirects=True diff --git a/lms/static/scripts/frontend_apps/components/LaunchErrorDialog.tsx b/lms/static/scripts/frontend_apps/components/LaunchErrorDialog.tsx index 385dc57754..f1710d4b23 100644 --- a/lms/static/scripts/frontend_apps/components/LaunchErrorDialog.tsx +++ b/lms/static/scripts/frontend_apps/components/LaunchErrorDialog.tsx @@ -356,6 +356,21 @@ export default function LaunchErrorDialog({

); + + case 'canvas_studio_admin_token_refresh_failed': + return ( + +

+ Your Canvas LMS administrator needs to re-authorize the integration + between Hypothesis and Canvas Studio. +

+
+ ); + case 'blackboard_group_set_not_found': return ( { hasRetry: false, withError: true, }, + { + errorState: 'canvas_studio_admin_token_refresh_failed', + expectedText: + 'Your Canvas LMS administrator needs to re-authorize the integration between Hypothesis and Canvas Studio', + expectedTitle: 'Unable to access Canvas Studio video', + hasRetry: false, + withError: true, + }, { errorState: 'd2l_file_not_found_in_course_instructor', expectedText: diff --git a/lms/static/scripts/frontend_apps/errors.ts b/lms/static/scripts/frontend_apps/errors.ts index 7cb06d1747..e0fef0d5fc 100644 --- a/lms/static/scripts/frontend_apps/errors.ts +++ b/lms/static/scripts/frontend_apps/errors.ts @@ -22,6 +22,7 @@ export type LTILaunchServerErrorCode = | 'canvas_studio_download_unavailable' | 'canvas_studio_transcript_unavailable' | 'canvas_studio_media_not_found' + | 'canvas_studio_admin_token_refresh_failed' | 'd2l_file_not_found_in_course_instructor' | 'd2l_file_not_found_in_course_student' | 'd2l_group_set_empty' @@ -169,6 +170,7 @@ export function isLTILaunchServerError(error: ErrorLike): error is APIError { 'canvas_studio_download_unavailable', 'canvas_studio_transcript_unavailable', 'canvas_studio_media_not_found', + 'canvas_studio_admin_token_refresh_failed', 'vitalsource_user_not_found', 'vitalsource_no_book_license', 'moodle_page_not_found_in_course', diff --git a/lms/views/api/refresh.py b/lms/views/api/refresh.py index 4d1cb14a0b..f214a49637 100644 --- a/lms/views/api/refresh.py +++ b/lms/views/api/refresh.py @@ -25,6 +25,10 @@ def get_refreshed_token_from_canvas(self): def get_refreshed_token_from_canvas_studio(self): self.request.find_service(CanvasStudioService).refresh_access_token() + @view_config(route_name="canvas_studio_api.oauth.refresh_admin") + def get_refreshed_admin_token_from_canvas_studio(self): + self.request.find_service(CanvasStudioService).refresh_admin_access_token() + @view_config(route_name="blackboard_api.oauth.refresh") def get_refreshed_token_from_blackboard(self): blackboard_api_client = self.request.find_service(name="blackboard_api_client") diff --git a/tests/unit/lms/services/canvas_studio_test.py b/tests/unit/lms/services/canvas_studio_test.py index ab346fde4d..53d7577df7 100644 --- a/tests/unit/lms/services/canvas_studio_test.py +++ b/tests/unit/lms/services/canvas_studio_test.py @@ -8,7 +8,11 @@ from lms.models.oauth2_token import Service from lms.services.canvas_studio import CanvasStudioService, factory -from lms.services.exceptions import ExternalRequestError, OAuth2TokenError +from lms.services.exceptions import ( + ExternalRequestError, + OAuth2TokenError, + SerializableError, +) from lms.services.oauth_http import OAuthHTTPService from tests import factories @@ -36,6 +40,29 @@ def test_refresh_access_token(self, svc, oauth_http_service, client_secret): auth=("the_client_id", client_secret), ) + def test_refresh_admin_access_token( + self, svc, admin_oauth_http_service, client_secret + ): + svc.refresh_admin_access_token() + + admin_oauth_http_service.refresh_access_token.assert_called_with( + "https://hypothesis.instructuremedia.com/api/public/oauth/token", + "http://example.com/api/canvas_studio/oauth/callback", + auth=("the_client_id", client_secret), + ) + + def test_refresh_admin_access_token_error(self, svc, admin_oauth_http_service): + response = factories.requests.Response(status_code=403) + admin_oauth_http_service.refresh_access_token.side_effect = ( + ExternalRequestError(response=response) + ) + + with pytest.raises(SerializableError) as exc_info: + svc.refresh_admin_access_token() + + assert exc_info.value.error_code == "canvas_studio_admin_token_refresh_failed" + assert exc_info.value.message == "Canvas Studio admin token refresh failed." + def test_authorization_url(self, svc): state = "the_callback_state" auth_url = svc.authorization_url(state) @@ -237,60 +264,20 @@ def test_get_video_download_url_fails_if_admin_not_authenticated( == "The Canvas Studio admin needs to authenticate the Hypothesis integration" ) - def test_admin_token_refreshed_if_needed( - self, admin_oauth_http_service, svc, client_secret + def test_get_video_download_url_when_admin_token_expired( + self, admin_oauth_http_service, svc ): - # Set up admin-authenticated OAuth request to fail due to expired token. - token_expired_response = factories.requests.Response(status_code=401) - original_get = admin_oauth_http_service.get.side_effect - admin_oauth_http_service.get.side_effect = ExternalRequestError( - response=token_expired_response - ) - - def refresh_ok(*_args, **_kwargs): - admin_oauth_http_service.get.side_effect = original_get - - def refresh_fail(*_args, **_kwargs): - raise ExternalRequestError(message="refresh failed") - - admin_oauth_http_service.refresh_access_token.side_effect = refresh_ok - - # Perform a request that is admin-authenticated. This should trigger - # a refresh and then succeed as normal. - url = svc.get_video_download_url("42") - - admin_oauth_http_service.refresh_access_token.assert_called_with( - "https://hypothesis.instructuremedia.com/api/public/oauth/token", - "http://example.com/api/canvas_studio/oauth/callback", - auth=("the_client_id", client_secret), - ) - assert url == "https://videos.cdn.com/video.mp4?signature=abc" - - # Set up the initial request to fail again, due to an expired token, - # but this time make the refresh fail. + response = factories.requests.Response(status_code=401) admin_oauth_http_service.get.side_effect = ExternalRequestError( - response=token_expired_response + response=response ) - admin_oauth_http_service.refresh_access_token.side_effect = refresh_fail - with pytest.raises(ExternalRequestError) as exc_info: + with pytest.raises(OAuth2TokenError) as exc_info: svc.get_video_download_url("42") - assert ( - exc_info.value.message - == "Canvas Studio admin token refresh failed. Ask the admin user to re-authenticate." - ) - - # Set up the initial request to fail again, due to an expired token, - # but this time make subsequent requests fail even though the refresh - # apparently succeeded. - admin_oauth_http_service.get.side_effect = ExternalRequestError( - response=token_expired_response - ) - admin_oauth_http_service.refresh_access_token.side_effect = None - - with pytest.raises(Exception) as exc_info: - svc.get_video_download_url("42") + assert exc_info.value.refreshable is True + assert exc_info.value.refresh_route == "canvas_studio_api.oauth.refresh_admin" + assert exc_info.value.refresh_service == Service.CANVAS_STUDIO def test_get_transcript_url_returns_url_if_published(self, svc): transcript_url = svc.get_transcript_url("42") diff --git a/tests/unit/lms/views/api/refresh_test.py b/tests/unit/lms/views/api/refresh_test.py index e74c2484c6..1069fa6285 100644 --- a/tests/unit/lms/views/api/refresh_test.py +++ b/tests/unit/lms/views/api/refresh_test.py @@ -25,6 +25,13 @@ def test_get_refreshed_token_from_canvas_studio(self, canvas_studio_service, vie canvas_studio_service.refresh_access_token.assert_called_once_with() + def test_get_refreshed_admin_token_from_canvas_studio( + self, canvas_studio_service, views + ): + views.get_refreshed_admin_token_from_canvas_studio() + + canvas_studio_service.refresh_admin_access_token.assert_called_once_with() + def test_get_refreshed_token_from_d2l(self, d2l_api_client, views): views.get_refreshed_token_from_d2l()