async def test_lti_13_login_handler_invokes_redirect_method( monkeypatch, lti13_auth_params, make_mock_request_handler ): """ Does the LTI13LoginHandler call the redirect function once it receiving the post request? """ monkeypatch.setenv( "LTI13_AUTHORIZE_URL", "http://my.lms.platform/api/lti/authorize_redirect" ) local_handler = make_mock_request_handler(LTI13LoginHandler) local_utils = LTIUtils() local_utils.convert_request_to_dict(lti13_auth_params) with patch.object( LTIUtils, "convert_request_to_dict", return_value=local_utils.convert_request_to_dict(lti13_auth_params), ): with patch.object( LTI13LaunchValidator, "validate_login_request", return_value=True ): with patch.object( LTI13LoginHandler, "authorize_redirect", return_value=None ) as mock_redirect: LTI13LoginHandler( local_handler.application, local_handler.request ).post() assert mock_redirect.called
def lti13_login_params_dict(lti13_login_params): """ Return the initial LTI 1.3 authorization request as a dict """ utils = LTIUtils() args = utils.convert_request_to_dict(lti13_login_params) return args
def post(self): """ Validates required login arguments sent from platform and then uses the authorize_redirect() method to redirect users to the authorization url. """ lti_utils = LTIUtils() validator = LTI13LaunchValidator() args = lti_utils.convert_request_to_dict(self.request.arguments) self.log.debug('Initial login request args are %s' % args) if validator.validate_login_request(args): login_hint = args['login_hint'] self.log.debug('login_hint is %s' % login_hint) lti_message_hint = args['lti_message_hint'] self.log.debug('lti_message_hint is %s' % lti_message_hint) client_id = args['client_id'] self.log.debug('client_id is %s' % client_id) redirect_uri = guess_callback_uri('https', self.request.host, self.hub.server.base_url) self.log.info('redirect_uri: %r', redirect_uri) state = self.get_state() self.set_state_cookie(state) # TODO: validate that received nonces haven't been received before # and that they are within the time-based tolerance window nonce_raw = hashlib.sha256(state.encode()) nonce = nonce_raw.hexdigest() self.authorize_redirect( client_id=client_id, login_hint=login_hint, lti_message_hint=lti_message_hint, nonce=nonce, redirect_uri=redirect_uri, state=state, )
async def test_authenticator_returns_auth_state_with_other_lms_vendor( lti11_authenticator, ): ''' Do we get a valid username with lms vendors other than canvas? ''' utils = LTIUtils() utils.convert_request_to_dict = MagicMock(name='convert_request_to_dict') utils.convert_request_to_dict(3, 4, 5, key='value') with patch.object(LTI11LaunchValidator, 'validate_launch_request', return_value=True): authenticator = LTI11Authenticator() handler = Mock( spec=RequestHandler, get_secure_cookie=Mock(return_value=json.dumps(['key', 'secret'])), request=Mock( arguments=mock_lti11_args('moodle'), headers={}, items=[], ), ) result = await authenticator.authenticate(handler, None) expected = { 'name': 'foo', 'auth_state': { 'course_id': 'intro101', 'lms_user_id': '185d6c59731a553009ca9b59ca3a885100000', 'user_role': 'Learner', }, } assert result == expected
def test_normalize_string_return_false_with_missing_name(): """ Does a missing container name raise a value error? """ container_name = '' utils = LTIUtils() with pytest.raises(ValueError): normalized_container_name = utils.normalize_string(container_name)
def test_normalize_string_with_long_name(): """ Does a container name with more than 20 characters get normalized? """ container_name = 'this_is_a_really_long_container_name' utils = LTIUtils() normalized_container_name = utils.normalize_string(container_name) assert len(normalized_container_name) <= 20
def test_normalize_string_raises_value_error_with_missing_name(): """ Does a missing container name raise a value error? """ container_name = "" utils = LTIUtils() with pytest.raises(ValueError): utils.normalize_string(container_name)
def test_email_to_username_retrieves_only_username_part_before_at_symbol(): """ Does the email_to_username method remove all after @? """ email = '*****@*****.**' utils = LTIUtils() # act result = utils.email_to_username(email) assert result == 'user1'
def test_normalize_string_with_special_characters(): """ Does a container name with with special characters get normalized? """ container_name = '#$%_this_is_a_container_name' utils = LTIUtils() normalized_container_name = utils.normalize_string(container_name) regex = re.compile('[@!#$%^&*()<>?/\\|}{~:]') assert regex.search(normalized_container_name) is None
def test_normalize_string_with_first_letter_as_alphanumeric(): """ Does a container name with start with an alphanumeric character? """ container_name = '___this_is_a_container_name' utils = LTIUtils() normalized_container_name = utils.normalize_string(container_name) regex = re.compile('_.-') first_character = normalized_container_name[0] assert first_character != regex.search(normalized_container_name)
def test_email_to_username_retrieves_only_first_part_before_plus_symbol(): """ Does the email_to_username method remove '+' symbol? """ email = '*****@*****.**' utils = LTIUtils() # act result = utils.email_to_username(email) assert result == 'user'
def test_email_to_username_converts_username_in_lowecase(): """ Does the email_to_username method convert to lowecase the username part? """ email = '*****@*****.**' utils = LTIUtils() # act result = utils.email_to_username(email) assert result == 'user_name1'
def test_get_protocol_with_more_than_one_value(): """ Are we able to determine the original protocol from the client's launch request? """ utils = LTIUtils() handler = Mock( spec=RequestHandler, request=Mock(headers={'x-forwarded-proto': 'https,http,http'}, protocol='https',), ) expected = 'https' protocol = utils.get_client_protocol(handler) assert expected == protocol
async def process_resource_link( logger: Any, course_id: str, jwt_body_decoded: Dict[str, Any], ) -> None: """ Executes additional processes with the claims that come only with LtiResourceLinkRequest """ # Values for send-grades functionality resource_link = jwt_body_decoded['https://purl.imsglobal.org/spec/lti/claim/resource_link'] resource_link_title = resource_link['title'] or '' course_lineitems = '' if ( 'https://purl.imsglobal.org/spec/lti-ags/claim/endpoint' in jwt_body_decoded and 'lineitems' in jwt_body_decoded['https://purl.imsglobal.org/spec/lti-ags/claim/endpoint'] ): course_lineitems = jwt_body_decoded['https://purl.imsglobal.org/spec/lti-ags/claim/endpoint']['lineitems'] nbgrader_service = NbGraderServiceHelper(course_id, True) nbgrader_service.update_course(lms_lineitems_endpoint=course_lineitems) if resource_link_title: assignment_name = LTIUtils().normalize_string(resource_link_title) logger.debug('Creating a new assignment from the Authentication flow with title %s' % assignment_name) # register the new assignment in nbgrader database nbgrader_service.register_assignment(assignment_name) # create the assignment source directory by calling the grader-setup service await create_assignment_source_dir(ORG_NAME, course_id, assignment_name)
async def test_lti_13_login_handler_sets_vars_for_redirect( monkeypatch, lti13_auth_params, lti13_auth_params_dict): """ Does the LTI13LoginHandler correctly set all variables needed for the redict method after receiving it from the validator? """ expected = lti13_auth_params_dict monkeypatch.setenv('LTI13_AUTHORIZE_URL', 'http://my.lms.platform/api/lti/authorize_redirect') local_handler = mock_handler(LTI13LoginHandler) local_utils = LTIUtils() with patch.object(LTIUtils, 'convert_request_to_dict', return_value=lti13_auth_params_dict): with patch.object(LTI13LaunchValidator, 'validate_launch_request', return_value=True): with patch.object(LTI13LoginHandler, 'redirect', return_value=None): assert expected['client_id'] == '125900000000000081' assert expected[ 'redirect_uri'] == 'https://acme.illumidesk.com/hub/oauth_callback' assert ( expected['lti_message_hint'] == 'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ2ZXJpZmllciI6IjFlMjk2NjEyYjZmMjdjYmJkZTg5YmZjNGQ1ZmQ5ZDBhMzhkOTcwYzlhYzc0NDgwYzdlNTVkYzk3MTQyMzgwYjQxNGNiZjMwYzM5Nzk1Y2FmYTliOWYyYTgzNzJjNzg3MzAzNzAxZDgxMzQzZmRmMmIwZDk5ZTc3MWY5Y2JlYWM5IiwiY2FudmFzX2RvbWFpbiI6ImlsbHVtaWRlc2suaW5zdHJ1Y3R1cmUuY29tIiwiY29udGV4dF90eXBlIjoiQ291cnNlIiwiY29udGV4dF9pZCI6MTI1OTAwMDAwMDAwMDAwMTM2LCJleHAiOjE1OTE4MzMyNTh9.uYHinkiAT5H6EkZW9D7HJ1efoCmRpy3Id-gojZHlUaA' ) assert expected[ 'login_hint'] == '185d6c59731a553009ca9b59ca3a885104ecb4ad' assert ( expected['state'] == 'eyJzdGF0ZV9pZCI6ICI2ZjBlYzE1NjlhM2E0MDJkYWM2MTYyNjM2MWQwYzEyNSIsICJuZXh0X3VybCI6ICIvIn0=' ) assert expected['nonce'] == '38048502278109788461591832959'
async def test_lti_13_login_handler_nonce(monkeypatch, lti13_auth_params, lti13_auth_params_dict): """ Do we get the expected nonce value result after hashing the state and returning the hexdigest? """ args_dict = lti13_auth_params_dict monkeypatch.setenv('LTI13_AUTHORIZE_URL', 'http://my.lms.platform/api/lti/authorize_redirect') local_handler = MagicMock(spec=LTI13LoginHandler) local_handler.request = lti13_auth_params local_utils = LTIUtils() with patch.object(LTIUtils, 'convert_request_to_dict', return_value=lti13_auth_params_dict): with patch.object(LTI13LaunchValidator, 'validate_launch_request', return_value=True): with patch.object(LTI13LoginHandler, 'redirect', return_value=None): expected = hashlib.sha256( b'eyJzdGF0ZV9pZCI6ICI2ZjBlYzE1NjlhM2E0MDJkYWM2MTYyNjM2MWQwYzEyNSIsICJuZXh0X3VybCI6ICIvIn0=' ).hexdigest() result = hashlib.sha256( args_dict['state'].encode()).hexdigest() assert expected == result
async def test_lti_13_login_handler_sets_state_with_next_url_obtained_from_target_link_uri( monkeypatch, lti13_login_params, make_mock_request_handler ): """ Do we get the expected nonce value result after hashing the state and returning the hexdigest? """ monkeypatch.setenv('LTI13_AUTHORIZE_URL', 'http://my.lms.platform/api/lti/authorize_redirect') lti13_login_params['target_link_uri'] = [ (lti13_login_params['target_link_uri'][0].decode() + '?next=/user-redirect/lab').encode() ] local_handler = make_mock_request_handler(LTI13LoginHandler) decoded_dict = LTIUtils().convert_request_to_dict(lti13_login_params) with patch.object(LTIUtils, 'convert_request_to_dict', return_value=decoded_dict): with patch.object(LTI13LaunchValidator, 'validate_login_request', return_value=True): with patch.object(LTI13LoginHandler, 'authorize_redirect', return_value=None): expected_state_json = { "state_id": "6f0ec1569a3a402dac61626361d0c125", "next_url": "/user-redirect/lab", } login_instance = LTI13LoginHandler(local_handler.application, local_handler.request) login_instance.post() assert login_instance._state state_decoded = _deserialize_state(login_instance._state) state_decoded['next_url'] == expected_state_json['next_url']
def test_convert_request_arguments_with_one_encoded_item_to_dict(): """ Do the items from a request object with one item per encoded value convert to a dict with decoded values? """ utils = LTIUtils() arguments = { "key1": [b"value1"], "key2": [b"value2"], "key3": [b"value3"], } expected = { "key1": "value1", "key2": "value2", "key3": "value3", } result = utils.convert_request_to_dict(arguments) assert expected == result
def test_convert_request_arguments_with_one_encoded_item_to_dict(): """ Do the items from a request object with one item per encoded value convert to a dict with decoded values? """ utils = LTIUtils() arguments = { 'key1': [b'value1'], 'key2': [b'value2'], 'key3': [b'value3'], } expected = { 'key1': 'value1', 'key2': 'value2', 'key3': 'value3', } result = utils.convert_request_to_dict(arguments) assert expected == result
def test_convert_request_arguments_with_more_than_one_encoded_item_to_dict(): """ Do the items from a request object with more than one item per encoded value convert to a dict with decoded values where the dict has one value per item? """ utils = LTIUtils() arguments = { "key1": [b"value1", b"valueA"], "key2": [b"value2", b"valueB"], "key3": [b"value3", b"valueC"], } expected = { "key1": "value1", "key2": "value2", "key3": "value3", } result = utils.convert_request_to_dict(arguments) assert expected == result
def nbgrader_format_db_url(course_id: str) -> str: """ Returns the nbgrader database url with the format: <org_name>_<course-id> Args: course_id: the course id (usually associated with the course label) from which the launch was initiated. """ course_id = LTIUtils().normalize_string(course_id) database_name = f"{org_name}_{course_id}" return f"postgresql://{nbgrader_db_user}:{nbgrader_db_password}@{nbgrader_db_host}:{nbgrader_db_port}/{database_name}"
async def setup_course_hook( authenticator: Authenticator, handler: RequestHandler, authentication: Dict[str, str] ) -> Dict[str, str]: """ Calls the microservice to setup up a new course in case it does not exist. The data needed is received from auth_state within authentication object. This function assumes that the required k/v's in the auth_state dictionary are available, since the Authenticator(s) validates the data beforehand. This function requires `Authenticator.enable_auth_state = True` and is intended to be used as a post_auth_hook. Args: authenticator: the JupyterHub Authenticator object handler: the JupyterHub handler object authentication: the authentication object returned by the authenticator class Returns: authentication (Required): updated authentication object """ lti_utils = LTIUtils() jupyterhub_api = JupyterHubAPI() # normalize the name and course_id strings in authentication dictionary course_id = lti_utils.normalize_string(authentication['auth_state']['course_id']) nb_service = NbGraderServiceHelper(course_id) username = lti_utils.normalize_string(authentication['name']) lms_user_id = authentication['auth_state']['lms_user_id'] user_role = authentication['auth_state']['user_role'] # register the user (it doesn't matter if it is a student or instructor) with her/his lms_user_id in nbgrader nb_service.add_user_to_nbgrader_gradebook(username, lms_user_id) # TODO: verify the logic to simplify groups creation and membership if user_is_a_student(user_role): # assign the user to 'nbgrader-<course_id>' group in jupyterhub and gradebook await jupyterhub_api.add_student_to_jupyterhub_group(course_id, username) elif user_is_an_instructor(user_role): # assign the user in 'formgrade-<course_id>' group await jupyterhub_api.add_instructor_to_jupyterhub_group(course_id, username) # launch the new (?) grader-notebook as a service setup_response = await register_new_service(org_name=ORG_NAME, course_id=course_id) return authentication
def test_convert_request_arguments_with_more_than_one_encoded_item_to_dict(): """ Do the items from a request object with more than one item per encoded value convert to a dict with decoded values where the dict has one value per item? """ utils = LTIUtils() arguments = { 'key1': [b'value1', b'valueA'], 'key2': [b'value2', b'valueB'], 'key3': [b'value3', b'valueC'], } expected = { 'key1': 'value1', 'key2': 'value2', 'key3': 'value3', } result = utils.convert_request_to_dict(arguments) assert expected == result
def __init__(self, course_id: str, check_database_exists: bool = False): if not course_id: raise ValueError('course_id missing') self.course_id = LTIUtils().normalize_string(course_id) self.course_dir = f'{mnt_root}/{org_name}/home/grader-{self.course_id}/{self.course_id}' self.uid = int(os.environ.get('NB_GRADER_UID') or '10001') self.gid = int(os.environ.get('NB_GID') or '100') self.db_url = nbgrader_format_db_url(course_id) self.database_name = f'{org_name}_{self.course_id}' if check_database_exists: self.create_database_if_not_exists()
async def test_lti_13_login_handler_empty_authorize_url_env_var_raises_environment_error( monkeypatch, lti13_login_params, lti13_login_params_dict, make_mock_request_handler ): """ Does the LTI13LoginHandler raise a missing argument error if request body doesn't have any arguments? """ monkeypatch.setenv('LTI13_AUTHORIZE_URL', '') local_handler = make_mock_request_handler(LTI13LoginHandler) local_utils = LTIUtils() with patch.object( LTIUtils, 'convert_request_to_dict', return_value=lti13_login_params_dict ) as mock_convert_request_to_dict: with patch.object(LTI13LaunchValidator, 'validate_launch_request', return_value=True): with patch.object(LTI13LoginHandler, 'redirect', return_value=None): with pytest.raises(EnvironmentError): LTI13LoginHandler(local_handler.application, local_handler.request).post()
async def test_lti_13_login_handler_invokes_validate_login_request_method( monkeypatch, lti13_auth_params, lti13_auth_params_dict): """ Does the LTI13LoginHandler call the LTI13LaunchValidator validate_login_request function once it receiving the post request? """ monkeypatch.setenv('LTI13_AUTHORIZE_URL', 'http://my.lms.platform/api/lti/authorize_redirect') local_handler = mock_handler(LTI13LoginHandler) local_utils = LTIUtils() with patch.object(LTIUtils, 'convert_request_to_dict', return_value=lti13_auth_params_dict): with patch.object(LTI13LaunchValidator, 'validate_login_request', return_value=True) as mock_validate_login_request: with patch.object(LTI13LoginHandler, 'authorize_redirect', return_value=None): LTI13LoginHandler(local_handler.application, local_handler.request).post() assert mock_validate_login_request.called
def mock_lti11_args( oauth_consumer_key: str, oauth_consumer_secret: str, ) -> Dict[str, str]: utils = LTIUtils() oauth_timestamp = str(int(time.time())) oauth_nonce = secrets.token_urlsafe(32) extra_args = {'my_key': 'this_value'} headers = {'Content-Type': 'application/x-www-form-urlencoded'} launch_url = 'http://jupyterhub/hub/lti/launch' args = { 'lti_message_type': 'basic-lti-launch-request', 'lti_version': 'LTI-1p0'.encode(), 'resource_link_id': '88391-e1919-bb3456', 'oauth_consumer_key': oauth_consumer_key, 'oauth_timestamp': str(int(oauth_timestamp)), 'oauth_nonce': str(oauth_nonce), 'oauth_signature_method': 'HMAC-SHA1', 'oauth_callback': 'about:blank', 'oauth_version': '1.0', 'user_id': '123123123', } args.update(extra_args) base_string = signature.signature_base_string( 'POST', signature.base_string_uri(launch_url), signature.normalize_parameters( signature.collect_parameters(body=args, headers=headers)), ) args['oauth_signature'] = signature.sign_hmac_sha1(base_string, oauth_consumer_secret, None) return args
async def process_resource_link_lti_13( logger: Any, course_id: str, jwt_body_decoded: Dict[str, Any], ) -> None: """ Executes additional processes with the claims that come only with LtiResourceLinkRequest """ # Values for send-grades functionality resource_link = jwt_body_decoded[ "https://purl.imsglobal.org/spec/lti/claim/resource_link"] resource_link_title = resource_link["title"] or "" nbgrader_service = NbGraderServiceHelper(course_id, True) if resource_link_title: assignment_name = LTIUtils().normalize_string(resource_link_title) logger.debug( "Creating a new assignment from the Authentication flow with title %s" % assignment_name) # register the new assignment in nbgrader database nbgrader_service.register_assignment(assignment_name) # create the assignment source directory by calling the grader-setup service await create_assignment_source_dir(ORG_NAME, course_id, assignment_name)
async def _get_line_item_info_by_assignment_name(self) -> str: await self._get_lineitems_from_url(self.course.lms_lineitems_endpoint) if not self.all_lineitems: raise GradesSenderMissingInfoError(f'No line-items were detected for this course: {self.course_id}') logger.debug(f'LineItems retrieved: {self.all_lineitems}') lineitem_matched = None for item in self.all_lineitems: item_label = item['label'] if ( self.assignment_name.lower() == item_label.lower() or self.assignment_name.lower() == LTIUtils().normalize_string(item_label) ): lineitem_matched = item['id'] # the id is the full url logger.debug(f'There is a lineitem matched with the assignment {self.assignment_name}. {item}') break if lineitem_matched is None: raise GradesSenderMissingInfoError(f'No lineitem matched with the assignment name: {self.assignment_name}') client = AsyncHTTPClient() resp = await client.fetch(lineitem_matched, headers=self.headers) lineitem_info = json.loads(resp.body) logger.debug(f'Fetched lineitem info from lms {lineitem_info}') return lineitem_info
async def get(self) -> None: """ Gets the JSON config which is used by LTI platforms to install the external tool. - The extensions key contains settings for specific vendors, such as canvas, moodle, edx, among others. - The tool uses public settings by default. Users that wish to install the tool with private settings should either copy/paste the json or toggle the application to private after it is installed with the platform. - Usernames are obtained by first attempting to get and normalize values sent when tools are installed with public settings. If private, the username is set using the anonumized user data when requests are sent with private installation settings. """ lti_utils = LTIUtils() self.set_header('Content-Type', 'application/json') # get the origin protocol protocol = lti_utils.get_client_protocol(self) self.log.debug('Origin protocol is: %s' % protocol) # build the full target link url value required for the jwks endpoint target_link_url = f'{protocol}://{self.request.host}/' self.log.debug('Target link url is: %s' % target_link_url) keys = { 'title': 'IllumiDesk', 'scopes': [ 'https://purl.imsglobal.org/spec/lti-ags/scope/lineitem', 'https://purl.imsglobal.org/spec/lti-ags/scope/lineitem.readonly', 'https://purl.imsglobal.org/spec/lti-ags/scope/result.readonly', 'https://purl.imsglobal.org/spec/lti-ags/scope/score', 'https://purl.imsglobal.org/spec/lti-nrps/scope/contextmembership.readonly', 'https://canvas.instructure.com/lti/public_jwk/scope/update', 'https://canvas.instructure.com/lti/data_services/scope/create', 'https://canvas.instructure.com/lti/data_services/scope/show', 'https://canvas.instructure.com/lti/data_services/scope/update', 'https://canvas.instructure.com/lti/data_services/scope/list', 'https://canvas.instructure.com/lti/data_services/scope/destroy', 'https://canvas.instructure.com/lti/data_services/scope/list_event_types', 'https://canvas.instructure.com/lti/feature_flags/scope/show', 'https://canvas.instructure.com/lti/account_lookup/scope/show', ], 'extensions': [{ 'platform': 'canvas.instructure.com', 'settings': { 'platform': 'canvas.instructure.com', 'placements': [ { 'placement': 'course_navigation', 'message_type': 'LtiResourceLinkRequest', 'windowTarget': '_blank', 'target_link_uri': target_link_url, 'custom_fields': { 'email': '$Person.email.primary', 'lms_user_id': '$User.id', }, # noqa: E231 }, { 'placement': 'assignment_selection', 'message_type': 'LtiResourceLinkRequest', 'target_link_uri': target_link_url, }, ], }, 'privacy_level': 'public', }], 'description': 'IllumiDesk Learning Tools Interoperability (LTI) v1.3 tool.', 'custom_fields': { 'email': '$Person.email.primary', 'lms_user_id': '$User.id', }, # noqa: E231 'public_jwk_url': f'{target_link_url}hub/lti13/jwks', 'target_link_uri': target_link_url, 'oidc_initiation_url': f'{target_link_url}hub/oauth_login', } self.write(json.dumps(keys))