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, )
def test_validate_with_required_params_in_initial_auth_request(lti13_login_params): """ Is the JWT valid with an correct message type claim? """ validator = LTI13LaunchValidator() result = validator.validate_login_request(lti13_login_params) assert result is True
def test_validate_empty_target_link_uri_request_claim_value(): """ Is the JWT valid with an empty target link uri claim? """ validator = LTI13LaunchValidator() jws = factory_lti13_resource_link_request() jws['https://purl.imsglobal.org/spec/lti/claim/target_link_uri'] = '' assert validator.validate_launch_request(jws) is True
def test_validate_empty_deployment_id_request_claim_value(): """ Is the JWT valid with an empty deployment_id claim? """ validator = LTI13LaunchValidator() jws = factory_lti13_resource_link_request() jws['https://purl.imsglobal.org/spec/lti/claim/deployment_id'] = '' assert validator.validate_launch_request(jws) is True
def test_validate_empty_roles_claim_value(make_lti13_resource_link_request): """ Is the JWT valid with an empty roles claim value? """ validator = LTI13LaunchValidator() jws = make_lti13_resource_link_request jws["https://purl.imsglobal.org/spec/lti/claim/roles"] = "" assert validator.validate_launch_request(jws)
def test_validate_claim_values_with_privacy_enabled( make_lti13_resource_link_request_privacy_enabled): """ Is the JWT valid when privacy is enabled? """ validator = LTI13LaunchValidator() jws = make_lti13_resource_link_request_privacy_enabled assert validator.validate_launch_request(jws)
def test_validate_empty_conext_label_claim_value(make_lti13_resource_link_request): """ Is the JWT valid with an empty context label claim? """ validator = LTI13LaunchValidator() jws = make_lti13_resource_link_request jws["https://purl.imsglobal.org/spec/lti/claim/context"]["label"] = "" with pytest.raises(HTTPError): validator.validate_launch_request(jws)
def test_validate_invalid_version_request_claim_value(make_lti13_resource_link_request): """ Is the JWT valid with an incorrect version claim? """ validator = LTI13LaunchValidator() jws = make_lti13_resource_link_request jws["https://purl.imsglobal.org/spec/lti/claim/version"] = "1.0.0" with pytest.raises(HTTPError): validator.validate_launch_request(jws)
def test_validate_invalid_resource_link_request_message_type_claim_value(): """ Is the JWT valid with an incorrect message type claim? """ validator = LTI13LaunchValidator() jws = factory_lti13_resource_link_request() jws['https://purl.imsglobal.org/spec/lti/claim/message_type'] = 'FakeLinkRequest' with pytest.raises(HTTPError): validator.validate_launch_request(jws)
def test_validate_empty_context_label_request_claim_value(): """ Is the JWT valid with an empty resource request id uri claim? """ validator = LTI13LaunchValidator() jws = factory_lti13_resource_link_request() jws['https://purl.imsglobal.org/spec/lti/claim/context']['label'] = '' with pytest.raises(HTTPError): validator.validate_launch_request(jws)
def test_validate_deep_linking_request_is_valid_with_message_type_claim( make_lti13_resource_link_request): """ Is the JWT valid with for LtiDeepLinkingRequest? """ validator = LTI13LaunchValidator() jws = make_lti13_resource_link_request jws['https://purl.imsglobal.org/spec/lti/claim/message_type'] = 'LtiDeepLinkingRequest' assert validator.validate_launch_request(jws)
def test_validate_missing_required_claims_in_step_2_resource_link_request(): """ Is the JWT valid with an incorrect message type claim? """ validator = LTI13LaunchValidator() fake_jws = { "key1": "value1", } with pytest.raises(HTTPError): validator.validate_login_request(fake_jws)
def test_validate_empty_resource_link_id_request_claim_value( make_lti13_resource_link_request): """ Is the JWT valid with an empty resource request id uri claim? """ validator = LTI13LaunchValidator() jws = make_lti13_resource_link_request jws['https://purl.imsglobal.org/spec/lti/claim/resource_link']['id'] = '' with pytest.raises(HTTPError): validator.validate_launch_request(jws)
async def test_validator_jwt_verify_and_decode_invokes_retrieve_matching_jwk(): """ Does the validator jwt_verify_and_decode method invoke the retrieve_matching_jwk method? """ validator = LTI13LaunchValidator() jwks_endoint = 'https://my.platform.domain/api/lti/security/jwks' with patch.object( validator, '_retrieve_matching_jwk', return_value=factory_lti13_platform_jwks() ) as mock_retrieve_matching_jwks: _ = await validator.jwt_verify_and_decode(dummy_lti13_id_token_complete, jwks_endoint, True) assert mock_retrieve_matching_jwks.called
async def test_validator_jwt_verify_and_decode_returns_none_with_no_retrieved_platform_keys(): """ Does the validator jwt_verify_and_decode method return None when no keys are returned from the retrieve_matching_jwk method? """ validator = LTI13LaunchValidator() jwks_endoint = 'https://my.platform.domain/api/lti/security/jwks' with patch.object( validator, '_retrieve_matching_jwk', return_value=factory_lti13_empty_platform_jwks() ) as mock_retrieve_matching_jwks: result = await validator.jwt_verify_and_decode(dummy_lti13_id_token_complete, jwks_endoint, True) assert result is None
def test_validate_resource_ling_is_not_required_for_deep_linking_request( make_lti13_resource_link_request, ): """ Is the JWT valid with for LtiDeepLinkingRequest? """ validator = LTI13LaunchValidator() jws = make_lti13_resource_link_request jws[ "https://purl.imsglobal.org/spec/lti/claim/message_type" ] = "LtiDeepLinkingRequest" del jws["https://purl.imsglobal.org/spec/lti/claim/resource_link"] assert validator.validate_launch_request(jws)
async def test_validator_jwt_verify_and_decode_invokes_retrieve_matching_jwk( make_lti13_resource_link_request, build_lti13_jwt_id_token): """ Does the validator jwt_verify_and_decode method invoke the retrieve_matching_jwk method? """ validator = LTI13LaunchValidator() jwks_endoint = 'https://my.platform.domain/api/lti/security/jwks' with patch.object(validator, '_retrieve_matching_jwk', return_value=None) as mock_retrieve_matching_jwks: _ = await validator.jwt_verify_and_decode( build_lti13_jwt_id_token(make_lti13_resource_link_request), jwks_endoint, True) assert mock_retrieve_matching_jwks.called
async def test_validator_jwt_verify_and_decode_raises_an_error_with_no_retrieved_platform_keys( http_async_httpclient_with_simple_response, make_lti13_resource_link_request, build_lti13_jwt_id_token): """ Does the validator jwt_verify_and_decode method return None when no keys are returned from the retrieve_matching_jwk method? """ validator = LTI13LaunchValidator() jwks_endoint = 'https://my.platform.domain/api/lti/security/jwks' with (pytest.raises(ValueError)): await validator.jwt_verify_and_decode( build_lti13_jwt_id_token(make_lti13_resource_link_request), jwks_endoint, True)
async def authenticate( # noqa: C901 self, handler: LTI13LoginHandler, data: Dict[str, str] = None) -> Dict[str, str]: """ Overrides authenticate from base class to handle LTI 1.3 authentication requests. Args: handler: handler object data: authentication dictionary Returns: Authentication dictionary """ lti_utils = LTIUtils() validator = LTI13LaunchValidator() # get jwks endpoint and token to use as args to decode jwt. we could pass in # self.endpoint directly as arg to jwt_verify_and_decode() but logging the self.log.debug("JWKS platform endpoint is %s" % self.endpoint) id_token = handler.get_argument("id_token") self.log.debug("ID token issued by platform is %s" % id_token) # extract claims from jwt (id_token) sent by the platform. as tool use the jwks (public key) # to verify the jwt's signature. jwt_decoded = await validator.jwt_verify_and_decode( id_token, self.endpoint, False, audience=self.client_id) self.log.debug("Decoded JWT is %s" % jwt_decoded) if validator.validate_launch_request(jwt_decoded): course_id = jwt_decoded[ "https://purl.imsglobal.org/spec/lti/claim/context"]["label"] course_id = lti_utils.normalize_string(course_id) self.log.debug("Normalized course label is %s" % course_id) username = "" if "email" in jwt_decoded and jwt_decoded["email"]: username = lti_utils.email_to_username(jwt_decoded["email"]) elif "name" in jwt_decoded and jwt_decoded["name"]: username = jwt_decoded["name"] elif "given_name" in jwt_decoded and jwt_decoded["given_name"]: username = jwt_decoded["given_name"] elif "family_name" in jwt_decoded and jwt_decoded["family_name"]: username = jwt_decoded["family_name"] elif ("https://purl.imsglobal.org/spec/lti/claim/lis" in jwt_decoded and "person_sourcedid" in jwt_decoded["https://purl.imsglobal.org/spec/lti/claim/lis"] and jwt_decoded["https://purl.imsglobal.org/spec/lti/claim/lis"] ["person_sourcedid"]): username = jwt_decoded[ "https://purl.imsglobal.org/spec/lti/claim/lis"][ "person_sourcedid"].lower() elif ("lms_user_id" in jwt_decoded[ "https://purl.imsglobal.org/spec/lti/claim/custom"] and jwt_decoded[ "https://purl.imsglobal.org/spec/lti/claim/custom"] ["lms_user_id"]): username = str(jwt_decoded[ "https://purl.imsglobal.org/spec/lti/claim/custom"] ["lms_user_id"]) # set role to learner role (by default) if instructor or learner/student roles aren't # sent with the request user_role = "Learner" for role in jwt_decoded[ "https://purl.imsglobal.org/spec/lti/claim/roles"]: if role.find("Instructor") >= 1: user_role = "Instructor" elif role.find("Learner") >= 1 or role.find("Student") >= 1: user_role = "Learner" self.log.debug("user_role is %s" % user_role) launch_return_url = "" if ("https://purl.imsglobal.org/spec/lti/claim/launch_presentation" in jwt_decoded and "return_url" in jwt_decoded[ "https://purl.imsglobal.org/spec/lti/claim/launch_presentation"] ): launch_return_url = jwt_decoded[ "https://purl.imsglobal.org/spec/lti/claim/launch_presentation"][ "return_url"] # if there is a resource link request then process additional steps if not validator.is_deep_link_launch(jwt_decoded): await process_resource_link_lti_13(self.log, course_id, jwt_decoded) lms_user_id = jwt_decoded[ "sub"] if "sub" in jwt_decoded else username # ensure the username is normalized self.log.debug("username is %s" % username) if not username: raise HTTPError(400, "Unable to set the username") # ensure the user name is normalized username_normalized = lti_utils.normalize_string(username) self.log.debug("Assigned username is: %s" % username_normalized) return { "name": username_normalized, "auth_state": { "course_id": course_id, "user_role": user_role, "lms_user_id": lms_user_id, "launch_return_url": launch_return_url, }, # noqa: E231 }
async def authenticate( # noqa: C901 self, handler: LTI13LoginHandler, data: Dict[str, str] = None ) -> Dict[str, str]: """ Overrides authenticate from base class to handle LTI 1.3 authentication requests. Args: handler: handler object data: authentication dictionary Returns: Authentication dictionary """ lti_utils = LTIUtils() validator = LTI13LaunchValidator() # get jwks endpoint and token to use as args to decode jwt. we could pass in # self.endpoint directly as arg to jwt_verify_and_decode() but logging the self.log.debug('JWKS platform endpoint is %s' % self.endpoint) id_token = handler.get_argument('id_token') self.log.debug('ID token issued by platform is %s' % id_token) # extract claims from jwt (id_token) sent by the platform. as tool use the jwks (public key) # to verify the jwt's signature. jwt_decoded = await validator.jwt_verify_and_decode(id_token, self.endpoint, False, audience=self.client_id) self.log.debug('Decoded JWT is %s' % jwt_decoded) if validator.validate_launch_request(jwt_decoded): course_id = jwt_decoded['https://purl.imsglobal.org/spec/lti/claim/context']['label'] course_id = lti_utils.normalize_string(course_id) self.log.debug('Normalized course label is %s' % course_id) username = '' if 'email' in jwt_decoded and jwt_decoded['email']: username = lti_utils.email_to_username(jwt_decoded['email']) elif 'name' in jwt_decoded and jwt_decoded['name']: username = jwt_decoded['name'] elif 'given_name' in jwt_decoded and jwt_decoded['given_name']: username = jwt_decoded['given_name'] elif 'family_name' in jwt_decoded and jwt_decoded['family_name']: username = jwt_decoded['family_name'] elif ( 'https://purl.imsglobal.org/spec/lti/claim/lis' in jwt_decoded and 'person_sourcedid' in jwt_decoded['https://purl.imsglobal.org/spec/lti/claim/lis'] and jwt_decoded['https://purl.imsglobal.org/spec/lti/claim/lis']['person_sourcedid'] ): username = jwt_decoded['https://purl.imsglobal.org/spec/lti/claim/lis']['person_sourcedid'].lower() elif ( 'lms_user_id' in jwt_decoded['https://purl.imsglobal.org/spec/lti/claim/custom'] and jwt_decoded['https://purl.imsglobal.org/spec/lti/claim/custom']['lms_user_id'] ): username = str(jwt_decoded['https://purl.imsglobal.org/spec/lti/claim/custom']['lms_user_id']) self.log.debug('username is %s' % username) # ensure the username is normalized self.log.debug('username is %s' % username) if username == '': raise HTTPError('Unable to set the username') # set role to learner role (by default) if instructor or learner/student roles aren't # sent with the request user_role = 'Learner' for role in jwt_decoded['https://purl.imsglobal.org/spec/lti/claim/roles']: if role.find('Instructor') >= 1: user_role = 'Instructor' elif role.find('Learner') >= 1 or role.find('Student') >= 1: user_role = 'Learner' self.log.debug('user_role is %s' % user_role) launch_return_url = '' if ( 'https://purl.imsglobal.org/spec/lti/claim/launch_presentation' in jwt_decoded and 'return_url' in jwt_decoded['https://purl.imsglobal.org/spec/lti/claim/launch_presentation'] ): launch_return_url = jwt_decoded['https://purl.imsglobal.org/spec/lti/claim/launch_presentation'][ 'return_url' ] # if there is a resource link request then process additional steps if not validator.is_deep_link_launch(jwt_decoded): await process_resource_link(self.log, course_id, jwt_decoded) lms_user_id = jwt_decoded['sub'] if 'sub' in jwt_decoded else username # ensure the user name is normalized username_normalized = lti_utils.normalize_string(username) self.log.debug('Assigned username is: %s' % username_normalized) return { 'name': username_normalized, 'auth_state': { 'course_id': course_id, 'user_role': user_role, 'lms_user_id': lms_user_id, 'launch_return_url': launch_return_url, }, # noqa: E231 }
async def authenticate( # noqa: C901 self, handler: LTI13LoginHandler, data: Dict[str, str] = None) -> Dict[str, str]: """ Overrides authenticate from base class to handle LTI 1.3 authentication requests. Args: handler: handler object data: authentication dictionary Returns: Authentication dictionary """ lti_utils = LTIUtils() validator = LTI13LaunchValidator() # get jwks endpoint and token to use as args to decode jwt. we could pass in # self.endpoint directly as arg to jwt_verify_and_decode() but logging the self.log.debug('JWKS platform endpoint is %s' % self.endpoint) id_token = handler.get_argument('id_token') self.log.debug('ID token issued by platform is %s' % id_token) # extract claims from jwt (id_token) sent by the platform. as tool use the jwks (public key) # to verify the jwt's signature. jwt_decoded = await validator.jwt_verify_and_decode( id_token, self.endpoint, False, audience=self.client_id) self.log.debug('Decoded JWT is %s' % jwt_decoded) if validator.validate_launch_request(jwt_decoded): course_id = jwt_decoded[ 'https://purl.imsglobal.org/spec/lti/claim/context']['label'] self.log.debug('Normalized course label is %s' % course_id) username = '' if 'email' in jwt_decoded.keys() and jwt_decoded.get('email'): username = lti_utils.email_to_username(jwt_decoded['email']) elif 'name' in jwt_decoded.keys() and jwt_decoded.get('name'): username = jwt_decoded.get('name') elif 'given_name' in jwt_decoded.keys() and jwt_decoded.get( 'given_name'): username = jwt_decoded.get('given_name') elif 'family_name' in jwt_decoded.keys() and jwt_decoded.get( 'family_name'): username = jwt_decoded.get('family_name') elif ('person_sourcedid' in jwt_decoded['https://purl.imsglobal.org/spec/lti/claim/lis'] and jwt_decoded['https://purl.imsglobal.org/spec/lti/claim/lis'] ['person_sourcedid']): username = jwt_decoded[ 'https://purl.imsglobal.org/spec/lti/claim/lis'][ 'person_sourcedid'].lower() if username == '': raise HTTPError('Unable to set the username') self.log.debug('username is %s' % username) # assign a workspace type, if provided, otherwise defaults to jupyter classic nb workspace_type = '' if ('https://purl.imsglobal.org/spec/lti/claim/custom' in jwt_decoded and jwt_decoded[ 'https://purl.imsglobal.org/spec/lti/claim/custom'] is not None): if ('workspace_type' in jwt_decoded[ 'https://purl.imsglobal.org/spec/lti/claim/custom'] and jwt_decoded[ 'https://purl.imsglobal.org/spec/lti/claim/custom'] ['workspace_type'] is not None): workspace_type = jwt_decoded[ 'https://purl.imsglobal.org/spec/lti/claim/custom'][ 'workspace_type'] if workspace_type not in WORKSPACE_TYPES: workspace_type = 'notebook' self.log.debug('workspace type is %s' % workspace_type) user_role = '' for role in jwt_decoded[ 'https://purl.imsglobal.org/spec/lti/claim/roles']: if role.find('Instructor') >= 1: user_role = 'Instructor' elif role.find('Learner') >= 1 or role.find('Student') >= 1: user_role = 'Learner' # set role to learner role if instructor or learner/student roles aren't # sent with the request if user_role == '': user_role = 'Learner' self.log.debug('user_role is %s' % user_role) lms_user_id = jwt_decoded[ 'sub'] if 'sub' in jwt_decoded else username # Values for the send-grades functionality course_lineitems = '' if 'https://purl.imsglobal.org/spec/lti-ags/claim/endpoint' in jwt_decoded: course_lineitems = jwt_decoded[ 'https://purl.imsglobal.org/spec/lti-ags/claim/endpoint'][ 'lineitems'] return { 'name': username, 'auth_state': { 'course_id': course_id, 'user_role': user_role, 'workspace_type': workspace_type, 'course_lineitems': course_lineitems, 'user_role': user_role, 'lms_user_id': lms_user_id, }, # noqa: E231 }