def test_validator_with_missed_fields( validator: LTI13LaunchValidator, jws: JsonType, field: str, value: str, passes: bool, ): ''' Is the JWT valid with an incorrect claim? ''' base = 'https://purl.imsglobal.org/spec/lti/claim/' if '/' in field: field, key = field.split('/') jws[base + field][key] = value else: jws[base + field] = value if passes: assert validator.validate_launch_request(jws) is True else: with pytest.raises(HTTPError): validator.validate_launch_request(jws)
def test_jwt_with_missed_claims(validator: LTI13LaunchValidator): ''' Is the JWT valid with an incorrect message type claim? ''' with pytest.raises(HTTPError): validator.validate_login_request({'key1': 'value1'})
def test_validate_with_required_params_in_initial_auth_request( validator: LTI13LaunchValidator, lti13_login_params: t.Dict[str, t.List[bytes]], ): ''' Is the JWT valid with an correct message type claim? ''' assert validator.validate_login_request(lti13_login_params) is True
def post(self) -> None: ''' Validates required login arguments sent from platform and then uses the authorize_redirect() method to redirect users to the authorization url. ''' validator = LTI13LaunchValidator() args = LTIHelper.convert_request_to_dict(self.request.arguments) self.log.debug('Initial login request args are %s' % dump_json( {**args, 'lti_message_hint': args['lti_message_hint'][-5:]})) 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[-5:]) 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: %s' % 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_resource_ling_is_not_required_for_deep_linking_request( validator: LTI13LaunchValidator, jws: JsonType, ): ''' Is the JWT valid with for LtiDeepLinkingRequest? ''' base = 'https://purl.imsglobal.org/spec/lti/claim/' jws[base + 'message_type'] = 'LtiDeepLinkingRequest' jws.pop(base + 'resource_link') assert validator.validate_launch_request(jws)
class LTI13Authenticator(OAuthenticator): '''Custom authenticator used with LTI 1.3 requests''' login_service = 'LTI13Authenticator' # handlers used for login, callback, and jwks endpoints login_handler = LTI13LoginHandler callback_handler = LTI13CallbackHandler helper = LTIHelper() validator = LTI13LaunchValidator() # the client_id, authorize_url, and token_url config settings # are available in the OAuthenticator base class. they are overriden here # for the sake of clarity. client_id = Unicode( '', help=''' The LTI 1.3 client id that identifies the tool installation with the platform. ''', ).tag(config=True) endpoint = Unicode( '', help=''' The platform's base endpoint used when redirecting requests to the platform after receiving the initial login request. ''', ).tag(config=True) oauth_callback_url = Unicode( os.getenv('LTI13_CALLBACK_URL', ''), config=True, help='''Callback URL to use. Should match the redirect_uri sent from the platform during the initial login request.''', ).tag(config=True) async def authenticate( self, handler: LTI13LoginHandler, *_whatever: t.Any, **__whatever: t.Any, ) -> JsonType: ''' Overrides authenticate from base class to handle LTI 1.3 authentication requests. Args: handler (LTI13LoginHandler): handler object **_ignored (t.Any) Returns: JsonType: Authentication dictionary ''' purl: str = 'https://purl.imsglobal.org/spec/lti/claim' self.log.debug(f'JWKS platform endpoint is {self.endpoint}') # 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 id_token: str = handler.get_argument('id_token') self.log.debug( f'Got ID token issued by platform: {len(id_token) if id_token else None}' ) # 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 self.validator.jwt_verify_and_decode( id_token, self.endpoint, verify=False, audience=self.client_id, ) self.log.debug(f'Decoded JWT is {dump_json(jwt_decoded)}') if self.validator.validate_launch_request(jwt_decoded): jwt_course_id = jwt_decoded[f'{purl}/context']['label'] course_id = self.helper.format_string(jwt_course_id) self.log.debug('Normalized course label is %s' % course_id) self.log.debug(json.dumps(jwt_decoded, indent=2)) username = jwt_decoded[purl + '/ext']['user_username'] # if 'email' in jwt_decoded and jwt_decoded['email']: # username = self.helper.email_to_username( # jwt_decoded['email']) # if '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 ( # f'{purl}/lis' in jwt_decoded # and 'person_sourcedid' # in jwt_decoded[f'{purl}/lis'] # and jwt_decoded[f'{purl}/lis']['person_sourcedid'] # ): # username = jwt_decoded[f'{purl}/lis']['person_sourcedid'].lower() # # elif ( # 'lms_user_id' in jwt_decoded[f'{purl}/custom'] # and jwt_decoded[f'{purl}/custom']['lms_user_id'] # ): # username = str(jwt_decoded[f'{purl}/custom']['lms_user_id']) # 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[f'{purl}/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 (f'{purl}/launch_presentation' in jwt_decoded and 'return_url' in jwt_decoded[f'{purl}/launch_presentation']): launch_return_url = jwt_decoded[f'{purl}/launch_presentation'][ 'return_url'] lms_user_id = jwt_decoded[ 'sub'] if 'sub' in jwt_decoded else username # ensure the user name is normalized # username_normalized = self.helper.format_string(username) # # self.log.debug('Assigned username is: %s' % username_normalized) return { 'name': username, 'auth_state': { 'course_id': course_id, 'user_role': user_role, 'lms_user_id': lms_user_id, 'launch_return_url': launch_return_url, }, }
def test_validate_with_privacy_enabled(validator: LTI13LaunchValidator, jws_with_privacy: JsonType): ''' Is the JWT valid when privacy is enabled? ''' assert validator.validate_launch_request(jws_with_privacy)
def validator() -> LTI13LaunchValidator: return LTI13LaunchValidator()