Beispiel #1
0
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
Beispiel #2
0
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
Beispiel #3
0
 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
Beispiel #7
0
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
Beispiel #10
0
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)
Beispiel #11
0
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'
Beispiel #12
0
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'
Beispiel #13
0
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
Beispiel #14
0
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)
Beispiel #15
0
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'
Beispiel #16
0
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']
Beispiel #18
0
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
Beispiel #19
0
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
Beispiel #20
0
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
Beispiel #21
0
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}"
Beispiel #22
0
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
Beispiel #23
0
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
Beispiel #24
0
    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()
Beispiel #26
0
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
Beispiel #28
0
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)
Beispiel #29
0
    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
Beispiel #30
0
    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))