dmitry-viskov / pylti1.3 Goto Github PK
View Code? Open in Web Editor NEWLTI 1.3 Advantage Tool
License: MIT License
LTI 1.3 Advantage Tool
License: MIT License
Hello,
Awesome package!
I'm using this package with fastapi and am working on an integration with canvas. I am able to launch the tool just fine and also to deep link a resource and launch that - but whenever I try to use one of the "services" such as nrps (specifically here the get_members() function) I am getting the following error
pylti1p3.exception.LtiServiceException: HTTP response [https://canvas.instructure.com/login/oauth2/token]: 400 - {"error":"invalid_request","error_description":"JWS signature invalid."}
more complete stack:
File "/usr/local/lib/python3.7/site-packages/pylti1p3/names_roles.py", line 66, in get_members
members, members_url = self.get_members_page(members_url)
File "/usr/local/lib/python3.7/site-packages/pylti1p3/names_roles.py", line 50, in get_members_page
accept='application/vnd.ims.lti-nrps.v2.membershipcontainer+json',
File "/usr/local/lib/python3.7/site-packages/pylti1p3/service_connector.py", line 107, in make_service_request
access_token = self.get_access_token(scopes)
File "/usr/local/lib/python3.7/site-packages/pylti1p3/service_connector.py", line 84, in get_access_token
raise LtiServiceException(r)
pylti1p3.exception.LtiServiceException: HTTP response [https://canvas.instructure.com/login/oauth2/token]: 400 - {"error":"invalid_request","error_description":"JWS signature invalid."}
I have tested the same setup with this platform testing tool https://saltire.lti.app/platform and nrps works fine I am able to get the member list just fine. I'm not sure where to look next to debug..
Any help would be greatly appreciated!
Thanks,
Joe
Hi Dmitry,
Thank you for the great work.
However, I am facing difficulty integrating the setup with canvas, specifically in the step 2 of the authentication process.
If I set the auth_login_url our implementation-specific: https://learn.acehsc.com.au/api/lti/authorize
Then I get the error "message":"An error occurred.","error_code":"internal_server_error"
This is the form data that gets posted to my tool\login at step 1:
iss: https://canvas.instructure.com
login_hint: c02ea24550b257158e3b4f8e1e077c79bb2ee590
client_id: 10000000000011
target_link_uri: https://acehsc.online/launch/
lti_message_hint: eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ2ZXJpZmllciI6IjVjNTdlYWFlOTM5YjRmZDJjM2UxZWRmM2Y5ZjFkODVlMTQ2ZmNhNDJmMzhkY2NiMTk4NTQyNjBkMDE2OGE4ZTQ3NmM1NmQ2OTA4ZjBlNGI3MTU3MjJkYTc3YjQ4MGJiNmU4YjUxMDdlOThiYzRjZjM4YjU3ZTU0YzRmZTEwZTQzIiwiY2FudmFzX2RvbWFpbiI6ImxlYXJuLmFjZWhzYy5jb20uYXUiLCJjb250ZXh0X3R5cGUiOiJDb3Vyc2UiLCJjb250ZXh0X2lkIjoxMDAwMDAwMDAwMDA1NiwiZXhwIjoxNTk0MTEzNjE3fQ.Tf-bqiP5hJajFY0Y_6gkQrPF372Ussvn7ECeOzjF2V0
canvas_region: not_configured
This is what my tool returns while redirecting to the https://learn.acehsc.com.au/api/lti/authorize
:
client_id: 10000000000011
login_hint: c02ea24550b257158e3b4f8e1e077c79bb2ee590
lti_message_hint: eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ2ZXJpZmllciI6IjVjNTdlYWFlOTM5YjRmZDJjM2UxZWRmM2Y5ZjFkODVlMTQ2ZmNhNDJmMzhkY2NiMTk4NTQyNjBkMDE2OGE4ZTQ3NmM1NmQ2OTA4ZjBlNGI3MTU3MjJkYTc3YjQ4MGJiNmU4YjUxMDdlOThiYzRjZjM4YjU3ZTU0YzRmZTEwZTQzIiwiY2FudmFzX2RvbWFpbiI6ImxlYXJuLmFjZWhzYy5jb20uYXUiLCJjb250ZXh0X3R5cGUiOiJDb3Vyc2UiLCJjb250ZXh0X2lkIjoxMDAwMDAwMDAwMDA1NiwiZXhwIjoxNTk0MTEzNjE3fQ.Tf-bqiP5hJajFY0Y_6gkQrPF372Ussvn7ECeOzjF2V0
nonce: d644906d47504bb682d6f4ed71d689d86136d3c4c03211eab109027500258144
prompt: none
redirect_uri: https://acehsc.online/launch/
response_mode: form_post
response_type: id_token
scope: openid
state: state-061e1b4b-e187-42ae-97ff-cd525d8746a9
As you can see the lti_message_hint
is the same, everything is right but it still returns internal_server_error
. I've been scratching my head over this for the last three days and any help would be highly appreciated.
If I set the auth_login_url
to https://canvas.instructure.com/api/lti/authorize_redirect
then I simply get Invalid lti_message_hint
error.
My JSON configuration file is attached below with the generic canvas configuration, I've tried changing and trying out different combinations of settings to no avail.
{
"https://canvas.instructure.com": {
"default": true,
"client_id": "10000000000011",
"auth_login_url": "https://canvas.instructure.com/api/lti/authorize_redirect",
"auth_token_url": "https://canvas.instructure.com/login/oauth2/token",
"key_set_url": "https://canvas.instructure.com/api/lti/security/jwks",
"key_set": null,
"private_key_file": "private.key",
"public_key_file": "public.key",
"deployment_ids": ["6:48a8d8c493e8d2aa9ce833888b4721865af0e06e"]
}
}
Tying to run flask example tool in Moodle. The game loaded successfully, and when JS trying get a score from Moodle, got this error.
127.0.0.1 - - [14/Oct/2021 03:20:09] "GET /api/scoreboard/lti1p3-launch-adcbe674-c539-46d7-bd71-a88fa3d41a6e/ HTTP/1.1" 500 -
Traceback (most recent call last):
File "C:\Users\disazoz\AppData\Roaming\Python\Python37\site-packages\flask\app.py", line 2464, in call
return self.wsgi_app(environ, start_response)
File "c:\Users\disazoz\Desktop\test\pylti1.3-flask-example\game\app.py", line 26, in call
return self.app(environ, start_response)
File "C:\Users\disazoz\AppData\Roaming\Python\Python37\site-packages\flask\app.py", line 2450, in wsgi_app
response = self.handle_exception(e)
File "C:\Users\disazoz\AppData\Roaming\Python\Python37\site-packages\flask\app.py", line 1867, in handle_exception
reraise(exc_type, exc_value, tb)
File "C:\Users\disazoz\AppData\Roaming\Python\Python37\site-packages\flask_compat.py", line 39, in reraise
raise value
File "C:\Users\disazoz\AppData\Roaming\Python\Python37\site-packages\flask\app.py", line 2447, in wsgi_app
response = self.full_dispatch_request()
File "C:\Users\disazoz\AppData\Roaming\Python\Python37\site-packages\flask\app.py", line 1952, in full_dispatch_request
rv = self.handle_user_exception(e)
File "C:\Users\disazoz\AppData\Roaming\Python\Python37\site-packages\flask\app.py", line 1821, in handle_user_exception
reraise(exc_type, exc_value, tb)
File "C:\Users\disazoz\AppData\Roaming\Python\Python37\site-packages\flask_compat.py", line 39, in reraise
raise value
File "C:\Users\disazoz\AppData\Roaming\Python\Python37\site-packages\flask\app.py", line 1950, in full_dispatch_request
rv = self.dispatch_request()
File "C:\Users\disazoz\AppData\Roaming\Python\Python37\site-packages\flask\app.py", line 1936, in dispatch_request
return self.view_functionsrule.endpoint
File "c:\Users\disazoz\Desktop\test\pylti1.3-flask-example\game\app.py", line 222, in scoreboard
scores = ags.get_grades(score_line_item)
File "C:\Programs\WPy64-3771\python-3.7.7.amd64\lib\site-packages\pylti1p3\assignments_grades.py", line 200, in get_grades
lineitem = self.find_or_create_lineitem(lineitem, find_by=find_by)
File "C:\Programs\WPy64-3771\python-3.7.7.amd64\lib\site-packages\pylti1p3\assignments_grades.py", line 157, in find_or_create_lineitem
lineitem = self.find_lineitem_by_tag(tag)
File "C:\Programs\WPy64-3771\python-3.7.7.amd64\lib\site-packages\pylti1p3\assignments_grades.py", line 142, in find_lineitem_by_tag
return self.find_lineitem('tag', tag)
File "C:\Programs\WPy64-3771\python-3.7.7.amd64\lib\site-packages\pylti1p3\assignments_grades.py", line 117, in find_lineitem
lineitems, lineitems_url = self.get_lineitems_page(lineitems_url)
File "C:\Programs\WPy64-3771\python-3.7.7.amd64\lib\site-packages\pylti1p3\assignments_grades.py", line 83, in get_lineitems_page
accept='application/vnd.ims.lis.v2.lineitemcontainer+json'
File "C:\Programs\WPy64-3771\python-3.7.7.amd64\lib\site-packages\pylti1p3\service_connector.py", line 110, in make_service_request
access_token = self.get_access_token(scopes)
File "C:\Programs\WPy64-3771\python-3.7.7.amd64\lib\site-packages\pylti1p3\service_connector.py", line 87, in get_access_token
raise LtiServiceException(r)
pylti1p3.exception.LtiServiceException: HTTP response [https://learn.qweasd.su/mod/lti/token.php]: 400 - {
"error" : "JWK must contain a "kty" parameter"
}
Not sure if it related, but i'm using private and public keys that were in the example git repo. The Moodle platform just self hosted instance.
It would be very helpful if this module had symbols defined for the URIs of the standard user roles. Symbols for various other URIs commonly used in LTI support would be helpful, too.
It would be useful to be able to provide custom HTTP headers, in particular User-Agent. Possibly the caller could provide an optional Requests session object to use.
We have seen LMSs use content filters that block requests with a User-Agent of 'python-requests'.
I have a [I think] sane django app... the service has been running for a few years, and happily uses both LTI 1.1 and SAML2 based authentication.
For reference: Django 3.1.13, running in a kubernetes cluster.
I'm now adding LTI 1.3 authentication, and my launch
process was failing.
I believe I've narrowed it down to the login
process not setting a nonce ... for the launch
process to then pick up on & verify.
This in my login code:
def lti1p3login(request):
logger.info(f"#### lti 1.3 login called")
tool_conf = get_tool_conf()
launch_data_storage = get_launch_data_storage()
oidc_login = DjangoOIDCLogin(request, tool_conf, launch_data_storage=launch_data_storage)
target_link_uri = get_launch_url(request)
foo = oidc_login.enable_check_cookies().redirect(target_link_uri)
# .redirect should have called ._prepare_redirect_url - which should have set stuff
logger.info(f" oidc example nonce (pylti1p3/oidc_login - lines 114-116): {oidc_login._generate_nonce()}") # lti1p3-nonce
nonce_key = oidc_login._session_service._get_key('nonce')
logger.info(f" oidc nonce (pylti1p3/oidc_login - lines 114-116): {oidc_login._session_service._get_value(nonce_key)}") # lti1p3-nonce
session_service = oidc_login._session_service
logger.info(f" launch storage : {launch_data_storage}")
logger.info(f"Returning oidc_login redirect {foo}")
return foo
This logs:
{"message": "#### lti 1.3 login called", "timestamp": "2022-03-28T10:17:39.186512+00:00", "level": "INFO"}
{"message": " oidc example nonce (pylti1p3/oidc_login - lines 114-116): 9f5e401c7def42cba7049935bc1971aa4bc4c44aae8011ecaec4c6252675a8d8", "timestamp": "2022-03-28T10:17:39.187694+00:00", "level": "INFO"}
{"message": " oidc nonce (pylti1p3/oidc_login - lines 114-116): None", "timestamp": "2022-03-28T10:17:39.199934+00:00", "level": "INFO"}
{"message": " launch storage : <pylti1p3.contrib.django.launch_data_storage.cache.DjangoCacheDataStorage object at 0x7f105f9fd0d0>", "timestamp": "2022-03-28T10:17:39.200087+00:00", "level": "INFO"}
{"message": "Returning oidc_login redirect <HttpResponse status_code=200, \"text/html; charset=utf-8\">", "timestamp": "2022-03-28T10:17:39.200663+00:00", "level": "INFO"}
127.0.0.1 - - [28/Mar/2022:11:17:39 +0100] "POST /lti1p3/login/ HTTP/1.1" 200 3246 .....<snip>.....
Am I wrong in assuming that the redirect()
call should have set something.... and that something should be retrievable from oidc_login._session_service._get_value(nonce_key)
?
Further info - after some investigation - it may be a cookie issue, if one of my colleagues does some "clever magic" so things appear as localhost
, it all works.
Hey Dmitry,
We wrote a small wrapper in our own code to use Flask-SQLAlchemy to pull LTI tool configs from our database rather than the ToolConfJson. I know it's easier to include it in Django because an ORM is built in. I would love it we could somehow get it included in the Flask contrib as an optional setting?
class LTIConfig(db.Model):
id = db.Column(db.Integer, primary_key=True)
iss = db.Column(db.String)
client_id = db.Column(db.String)
auth_login_url = db.Column(db.String)
auth_token_url = db.Column(db.String)
key_set_url = db.Column(db.String)
private_key_file = db.Column(db.String)
public_key_file = db.Column(db.String)
deployment_id = db.Column(db.String)
def get_lti_config(iss, client_id):
lti = LTIConfig.query.filter_by(iss=iss, client_id=client_id).first()
settings = {
lti.iss: [{
"client_id": lti.client_id,
"auth_login_url": lti.auth_login_url,
"auth_token_url": lti.auth_token_url,
"auth_audience": "null",
"key_set_url": lti.key_set_url,
"key_set": None,
"deployment_ids": [lti.deployment_id]
}]
}
private_key = lti.private_key_file
public_key = lti.public_key_file
tool_conf = ToolConfDict(settings)
tool_conf.set_private_key(iss, private_key, client_id=client_id)
tool_conf.set_public_key(iss, public_key, client_id=client_id)
return tool_conf
@app.route('/login/', methods=['GET', 'POST'])
def login():
session['iss'] = request.values.get('iss')
session['client_id'] = request.values.get('client_id')
tool_conf = get_lti_config(session['iss'], session['client_id'])
...
@app.route('/launch/', methods=['POST'])
def launch():
tool_conf = get_lti_config(session['iss'], session['client_id'])
flask_request = FlaskRequest()
...
I have no idea how to add this to the library as an optional item though.
Hello everyone,
I wanted to confirm whether this an issue with pylti1.3 or if I'm missing something, when adding multiple assignment grades through the LTI Assigments and Grades Services with the programmatic mode active, rather than the declarative/default grading mode.
I'm able to register and read back grades (line items) into the LMS. But none of them are registered as the actual score on the LMS side. If my understanding is correct, one of these line items needs to be selected by specifying the resourceLinkId. pylti1.3 does allow setting the resource_id, but not the resource_link_id.
Am I missing something?
I'm using as reference this score() function from the repo
def score(launch_id, earned_score, time_spent):
tool_conf = ToolConfJsonFile(get_lti_config_path())
flask_request = FlaskRequest()
launch_data_storage = get_launch_data_storage()
message_launch = ExtendedFlaskMessageLaunch.from_cache(launch_id, flask_request, tool_conf,
launch_data_storage=launch_data_storage)
resource_link_id = message_launch.get_launch_data() \
.get('https://purl.imsglobal.org/spec/lti/claim/resource_link', {}).get('id')
if not message_launch.has_ags():
raise Forbidden("Don't have grades!")
sub = message_launch.get_launch_data().get('sub')
timestamp = datetime.datetime.utcnow().isoformat() + 'Z'
earned_score = int(earned_score)
time_spent = int(time_spent)
grades = message_launch.get_ags()
sc = Grade()
sc.set_score_given(earned_score) \
.set_score_maximum(100) \
.set_timestamp(timestamp) \
.set_activity_progress('Completed') \
.set_grading_progress('FullyGraded') \
.set_user_id(sub)
sc_line_item = LineItem()
sc_line_item.set_tag('score') \
.set_score_maximum(100) \
.set_label('Score')
if resource_link_id:
sc_line_item.set_resource_id(resource_link_id)
grades.put_grade(sc, sc_line_item)
tm = Grade()
tm.set_score_given(time_spent) \
.set_score_maximum(999) \
.set_timestamp(timestamp) \
.set_activity_progress('Completed') \
.set_grading_progress('FullyGraded') \
.set_user_id(sub)
tm_line_item = LineItem()
tm_line_item.set_tag('time') \
.set_score_maximum(999) \
.set_label('Time Taken')
if resource_link_id:
tm_line_item.set_resource_id(resource_link_id)
result = grades.put_grade(tm, tm_line_item)
return jsonify({'success': True, 'result': result.get('body')})
Hi Dimitry,
I have a connection with a brightspace instance and try to retrieve grades.
I now receive a HTTP response [https://auth.brightspace.com/core/connect/token]: 400 - {"error":"invalid_grant","error_description":"Error validating assertion:
I just some code found in your test script.
{{{
if not message_launch.has_ags():
raise Exception("Don't have assignments and grades!")
else:
score_line_item = LineItem()
score_line_item.set_tag('score') \
.set_score_maximum(100) \
.set_label('Score')
scores = message_launch.validate_registration().get_ags().get_grades(score_line_item)
}}}
Hey All,
Trying to deploy a Django <-> uWSGI <-> Nginx application using pylti1.3 and whenever I put nginx in front of the application I get an Invalid Nonce error.
It's on AWS, nginx on http, ALB is https doing http-https conversion.
Any thoughts?
It works fine using Django built in server and uwsgi as the http server.
Hey! I was just debugging through the get_access_token
method of the ServiceConnector
class and saw it expects successful (2xx or 3xx) responses to contain an "access_token"
claim:
pylti1.3/pylti1p3/service_connector.py
Line 87 in 476556a
I was recently working on an integration with a Moodle-based LMS, and realized that APIs for those kind of LMSes return a claim with a different name for the access token. Particularly, their OAuth Token URL's endpoint lives at the /login/token.php
, and the token itself is provided as a "token"
claim. e.g.:
{
"token": "8b1cb0bebd237b919c71d7a8f487b4d2",
"privatetoken": "U7XZRAaVmielUhkGcYVBKRxXEGhQ9wWCOeHQ2MyMR9jb6oA9TqZ2YdCKhJ7UgxGE"
}
The Moodle-sided code that returns this claim is defined here: https://github.com/moodle/moodle/blob/5ee0b620ae99bebeca9ee2c18078ee010913ae65/login/token.php#L104. Hence, I think that a KeyError
would be raised in the case where a Moodle API is hit to retrieve an access token, since the "access_token"
claim would not be present even though the response is successful.
If you think this is case is worth considering, I would be happy to help in implementing it.
Best regards,
Pepe
You can call put_grade() without a line item, and if none is provided in the message launch data, it will try to create one:
pylti1.3/pylti1p3/assignments_grades.py
Lines 49 to 53 in 476556a
However, the one it creates has no tag, so find_or_create_lineitem raises an exception:
pylti1.3/pylti1p3/assignments_grades.py
Lines 174 to 177 in 476556a
Hello,
we have a few questions:
Django 4.0 removed ugettext and ugettext_lazy, which has been deprecated since Django 3.0. There is a patch available here but a PR has not been issued yet.
Could you integrate this?
The OIDCLogin.redirect() method takes the launch_url as a parameter, but get_cookies_allowed_js_check() always uses the target_link_uri from the original request rather than the launch_url passed.
I have multiple launch URLs in my application including some with parameters, but OpenID Connect security requires the redirect URIs to be whitelisted in at the OP (LTI tool) and must match exactly (no query strings or wildcards). So I want to use a fixed redirect_uri by passing that uri into OIDCLogin.redirect(). But get_cookies_allowed_js_check() ignores that.
When the provider redirects back the original target_link_uri is available as a claim in the jwt. Or it could be saved in the session using pass_params_to_launch(). Or both, and verified.
After many tries I still don't understand the problem between Moodle and pylti1.3.
I able to manage LMS moodle 3.11 with LTI plugin WP, LTI handshake is good.
Add LTI Advantage : I use "auto registration/configuration" when everyone is compatible it is happiness. This is not the case of Pylti1.3, but not blocking. -> You have understood this mechanism saves time and avoids filling out a form and making mistakes
I need your help, I can't get PyLti1.3 to work with Moodle. I have a strange behavior.
Moodle : says one and only thing : Invalid Request
POST Status code 200 ?lti-tool -> TOOL
params:
POST 200 /login/ --> TOOL pylti1.3
GET 302 http://xxxx:9001/login/
Hi from Brazil!
I really dont have any experience with servers and security, but following some tutorials I managed to make work a xampp server, where my moodle instance is running with a self generated certificate, to test my Tool, that it is also running with a self generated certificate. But i am facing some problem with the SSL part during the launch flow, in these routes:
@lti_routes.route(tool.configs.initiate_login_uri, methods=['GET', 'POST'])
def oidc_init():
config = ToolConfJsonFile(r'RemoteLabFPGA\lti\utils\register_config.json')
flask_request = FlaskRequest()
launch_data_storage = FlaskCacheDataStorage(cache)
login = FlaskOIDCLogin(request=flask_request, tool_config=config, launch_data_storage=launch_data_storage)
return login.enable_check_cookies().redirect(tool.claims.redirect_uris[0])
@lti_routes.route(tool.configs.redirect_uri, methods=['GET', 'POST'])
def oidc_launch():
config = ToolConfJsonFile(r'RemoteLabFPGA\lti\utils\register_config.json')
flask_request = FlaskRequest()
launch_data_storage = FlaskCacheDataStorage(cache)
message_launch = FlaskMessageLaunch(request=flask_request, tool_config=config, launch_data_storage=launch_data_storage)
message_launch_data = message_launch.get_launch_data()
return redirect(url_for('lab_routes.target_link'))
During the first route, the flow makes a call to the redirect function, that calls the CookiesAllowedCheckPage function, and then makes de JS function, and reload the same route. During this process of reload this route, my page changes from secure connection with valide certificate to not secure connection but stills with a valide certificate. After this, in this second run in this route, the redirect function call the second route, making the launch. In this part, the flow always broke during the FlaskMessageLaunch, when trying to get_public_key for the validate_jwt_signature, saying that is a SSL problem, that I believe is caused from the changing in the connection type (but may be wrong lol).
self.validate()
File "RemoteLabFPGA\venv\lib\site-packages\pylti1p3\message_launch.py", line 284, in validate
return self.validate_state()\
File "RemoteLabFPGA\venv\lib\site-packages\pylti1p3\message_launch.py", line 671, in validate_jwt_signature
public_key = self.get_public_key()
File "RemoteLabFPGA\venv\lib\site-packages\pylti1p3\message_launch.py", line 559, in get_public_key
public_key_set = self.fetch_public_key(key_set_url)
File "RemoteLabFPGA\venv\lib\site-packages\pylti1p3\message_launch.py", line 540, in fetch_public_key
raise LtiException("Error during fetch URL " + key_set_url + ": " + str(e))
pylti1p3.exception.LtiException: Error during fetch URL https://localhost/moodle/mod/lti/certs.php: HTTPSConnectionPool(host='localhost', port=443): Max retries exceeded with url: /moodle/mod/lti/certs.php (Caused by SSLError(SSLCertVerificationError(1, '[SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed: unable to get local issuer certificate (_ssl.c:1045)')))
I really dont know if the problem is with the way I configured the pylti1.3 or the xampp + certificates, but would appreciate some help! I dont understand why the type of connection is changing during the reload.
Hi,
I put your demo project in Brightspace.
Somtimes it works, I see the game.hml. other times , refresh, I get an nonce exception
Do you have any suggestions how to fix this?
Regards, Marcel
For Python 3
import json
from jwcrypto.jwk import JWK
f = open("jwtRS256.key.pub", "rb")
public_key = f.read()
f.close()
jwk_obj = JWK.from_pem(public_key)
public_jwk = json.loads(jwk_obj.export_public())
public_jwk['alg'] = 'RS256'
public_jwk['use'] = 'sig'
public_jwk_str = json.dumps(public_jwk)
print(public_jwk_str)
I'm struggling to find the correct value for "auth_login_url" using Moodle. Can you provide some guidance? Thank you.
Can pylti1.3 be used to create an LTI consumer? I'm looking around for examples of a LTI 1.3 consumer in python but am having trouble finding any.
I've a working system - I can connect from a test moodle to my tool - and now I want to add a test for the code.
My test code starts:
Overall imports
from rest_framework_jwt.settings import api_settings as jwt_settings
from mock import patch
from unittest.mock import Mock
from urllib import parse
from django.test import Client
from myApp.lti1p3_backend import ToolConfMyApp # My version of DjangoDbToolConf
from myApp_utils.test_utils import TestCreators # some helper methods for creating test objects in the db
class TestToolConfMyApp(TestCreators):
There are a whole suite of tests for the ToolConfMyApp
functions: check_iss_has_one_client
, check_iss_has_many_clients
, get_public_key
, get_iss_config
, etc...
I've even got POST
and GET
tests for login - and test the parameters in the response.url
from those calls
For the specific launch test - this is what I start with:
@patch('pylti1p3.message_launch.MessageLaunch.fetch_public_key')
def test_launch(self, mock_fpk: Mock):
# Set up the environment, and the initial login
key = self.create_lti1p3_tool_key(name="test", private_key="foo", public_key=pub_key)
self.create_lti1p3_tool(
title="test",
tool_key=key,
issuer="example_dot_com",
client_id="foo",
auth_login_url="http://example.com/a",
auth_token_url="http://example.com/b",
deployment_ids='["test-id-1", "test-id-2"]',
key_set_url="http://example.com/c",
)
c = Client()
response = c.get("/lti1p3/login/?lti1p3_new_window=1&iss=example_dot_com&login_hint=5&target_link_uri=http%3A%2F%2Fexample.com%2Flti1p3%2Flaunch%2F<i_message_hint=5<i_deployment_id=3&client_id=foo")
# Cookies should now exist, test launch
Then we want to set up the token for actual call:
query_string = parse.urlparse(response.url)[4]
query_dict = parse.parse_qs(query_string)
tool_conf = ToolConfNoteable()
mock_fpk.return_value = tool_conf.get_jwks("example_dot_com")
# create id_token that VLE creates - needs nonce to match login nonce!
source_token = {
"nonce": query_dict["nonce"][0],
"iat": 1649251764,
"exp": 1649251824,
"iss": "example_dot_com",
"aud": "foo",
"https://purl.imsglobal.org/spec/lti/claim/deployment_id": "test-id-1",
"https://purl.imsglobal.org/spec/lti/claim/target_link_uri": "http://vle.example.com/lti1p3/launch/",
"sub": "5",
"https://purl.imsglobal.org/spec/lti/claim/lis": {
"person_sourcedid": "",
"course_section_sourcedid": ""
},
"https://purl.imsglobal.org/spec/lti/claim/roles": [
"http://purl.imsglobal.org/vocab/lis/v2/institution/person#Administrator",
"http://purl.imsglobal.org/vocab/lis/v2/membership#Instructor",
"http://purl.imsglobal.org/vocab/lis/v2/system/person#Administrator"
],
"https://purl.imsglobal.org/spec/lti/claim/context": {
"id": "3",
"label": "sample_course",
"title": "An Example of a Course Title",
"type": [
"CourseSection"
]
},
"https://purl.imsglobal.org/spec/lti/claim/resource_link": {
"title": "some link text",
"id": "3"
},
"https://purl.imsglobal.org/spec/lti-bo/claim/basicoutcome": {
"lis_result_sourcedid": "{\"data\":{\"instanceid\":\"3\",\"userid\":\"5\",\"typeid\":\"3\",\"launchid\":1293835966},\"hash\":\"f85b809cb4b10a0bb65ce2824e8c892a2d08f6a2015edd68d3ccaf014486cf81\"}",
"lis_outcome_service_url": "https://vle.example.com/lti/service.php"
},
"given_name": "Jo",
"family_name": "Doe",
"name": "Jo Doe",
"https://purl.imsglobal.org/spec/lti/claim/ext": {
"user_username": "student1",
"lms": "moodle-2"
},
"https://purl.imsglobal.org/spec/lti/claim/launch_presentation": {
"locale": "en",
"document_target": "window",
"return_url": "https://vle.example.com/lti/return.php?course=3&launch_container=4&instanceid=3&sesskey=BKH3oeWS3f"
},
"https://purl.imsglobal.org/spec/lti/claim/tool_platform": {
"product_family_code": "moodle",
"version": "2020061512",
"guid": "vle.example.com",
"name": "vle.example.com",
"description": "VLE for example.com"
},
"https://purl.imsglobal.org/spec/lti/claim/version": "1.3.0",
"https://purl.imsglobal.org/spec/lti/claim/message_type": "LtiResourceLinkRequest",
"https://purl.imsglobal.org/spec/lti/claim/custom": {
"course_suffix": "foo",
"system_setting_url": "https://vle.example.com/lti/services.php/tool/3/custom",
"context_setting_url": "https://vle.example.com/lti/services.php/CourseSection/3/bindings/tool/3/custom",
"link_setting_url": "https://vle.example.com/lti/services.php/links/{link_id}/custom"
},
"https://purl.imsglobal.org/spec/lti-ags/claim/endpoint": {
"scope": [
"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"
],
"lineitems": "https://vle.example.com/lti/services.php/3/lineitems?type_id=3",
"lineitem": "https://vle.example.com/lti/services.php/3/lineitems/5/lineitem?type_id=3"
}
}
jwt_encode_handler = jwt_settings.JWT_ENCODE_HANDLER
token = jwt_encode_handler(source_token)
response = c.post("/lti1p3/launch/?", {"state": query_dict["state"][0], "id_token": token})
.... and it fails with a pylti1p3.exception.LtiException: JWT KID not found
error.
The full error reads:
Traceback (most recent call last):
File \"/path/to/lib/python3.7/site-packages/django/core/handlers/exception.py\", line 47, in inner
response = get_response(request)
File \"/path/to/lib/python3.7/site-packages/django/core/handlers/base.py\", line 181, in _get_response
response = wrapped_callback(request, *callback_args, **callback_kwargs)
File \"/path/to/lib/python3.7/site-packages/sentry_sdk/integrations/django/views.py\", line 36, in sentry_wrapped_callback
return callback(request, *args, **kwargs)
File \"/path/to/lib/python3.7/site-packages/django/views/generic/base.py\", line 70, in view
return self.dispatch(request, *args, **kwargs)
File \"/different/path/to/mycomponent/api/views.py\", line 554, in dispatch
return super().dispatch(*args, **kwargs)
File \"/different/path/to/mycomponent/api/views.py\", line 494, in dispatch
payload = self.add_extra_jwt_claims(payload)
File \"/different/path/to/mycomponent/api/views.py\", line 564, in add_extra_jwt_claims
message_launch_data = message_launch.get_launch_data()
File \"/path/to/lib/python3.7/site-packages/pylti1p3/message_launch.py\", line 494, in get_launch_data
return self._get_jwt_body()
File \"/path/to/lib/python3.7/site-packages/pylti1p3/message_launch.py\", line 299, in _get_jwt_body
self.validate()
File \"/path/to/lib/python3.7/site-packages/pylti1p3/message_launch.py\", line 284, in validate
return self.validate_state()\\
File \"/path/to/lib/python3.7/site-packages/pylti1p3/message_launch.py\", line 671, in validate_jwt_signature
public_key = self.get_public_key()
File \"/path/to/lib/python3.7/site-packages/pylti1p3/message_launch.py\", line 569, in get_public_key
raise LtiException(\"JWT KID not found\")
pylti1p3.exception.LtiException: JWT KID not found
Digging into the code, the problem seems to be the contents of self._jwt
in the MessageLaunch.get_public_key
method has the id_token
rather than the jwks
Any hints as to where I'm going wrong?
EDIT:
I can confirm that tool_conf.get_jwks("example_dot_com")
returns an appropriate token:
{
"alg": "RS256",
"e": "AQAB",
"kid": "9uXgneuBIDrQQprq9DVtQ0Za-elqN5wHsviNOtLmQdY",
"kty": "RSA",
"n": "u9omeEgZmWHV0448LhGFFUzpGynHhLN88sdbbHP7tEd67zcqfbSoeWiIWbVCwXFVrkTZNjXb5YANNcBrj7bOj6nGNvevYMM5YgxhfjvJ2rbAfPNtjeUzaj95la2wO14rc9b32FmMSzLE5xn5I8HqXZUHPH0W5VS3avBp_v-dyLTacXmlvzvDgJ2gNT9vPiUWLXSS9SajIYr7Sj5IILjSlHod5UkHGSyi7sK5vSpArOh3LbzM0fPPCqupIb6bouDgYRxjUqSphvY891nRMCWtQKXO4aOk65AqqQsRnQovLiyDxPYMesc3kZcNsuPlWyF5YNE81EcmdXRnrkBKqowdZQ",
"use": "sig",
}
I am trying to learn this library, and am having difficulty understanding parts of it. Is there any further documentation than what was included in the README? Some explanation of the individual classes and important functions would be beneficial to my efforts in understanding this library.
The ToolConfJsonFile
class includes support for reading the public and private key files based on the contents of the public_key_file
and private_key_file
config properties. The ToolConfDict
class, on which it is based, does not. If the dictionary support class could use those same properties to load the key files, it would allow us to simplify our code. We currently end up duplicating the code of ToolConfJsonFile._process_iss_conf_item
in our code.
I think if this were supported in the dictionary class, you couldn't support _configs_dir
the same way, but it wouldn't be a major problem.
I have a idea of how this could be implemented. I can open a pull request for this, if you'd like.
Hi Dmitry,
I look up the document about LTI 1.3.
When I try to do OIDCAuthorization, I try to call API to https://canvas.instructure.com/api/lti/authorize_redirect, I can't find the specific parameters I should use, and don't know how I generate the parameter state
.
Could you please spare some time to talk more?
[https://canvas.instructure.com/doc/api/file.lti_dev_key_config.html]
Many thanks,
Lucy
AssignmentsGradesService.find_or_create_lineitem
does not check its scopes before making the service call to create a new lineitem. On Moodle the user can configure the tool to access to put scores to the default line item but not to create new lineitems.
While reading through the README documentation for this library, I noticed there were a couple of minor grammatical errors that could be fixed to improve documentation readability.
I can detail what errors I noticed and/or make a PR with the corrections.
Hey
My name is Taras
We faced with the issue described below:
We have a problem with the tool service.
For example, we have the service "IMS LTI Assignment and Grade Services" in your library is a method "put_grade" this method makes a query "https://learn.catalynk.net/mod/lti/services.php/7/lineitems?type_id=3" in order to see line-items in the log this query returns 401
and such an error with "IMS Titles and LTI roles” on this request “https://learn.catalynk.net/mod/lti/services.php/CourseSection/7/bindings/3/memberships”.
This error occurred after the deployment on Heroku local errors are not local, I used this docker-compose for moodle
I think my moodle settings are correct
Maybe you'll have some ideas
Also here is a piece of code for a clear understanding
Thanks and look forward to hear your thoughts
Taras
the ServiceConnector.make_service_request() method decodes the body as JSON regardless of the content-type of the response.
The LTI basic outcome service returns XML, which of course fails to decode as JSON.
Hi,
I have an LTI 1.3 tool that I'm connecting to different LMSes for launching a webpage and then reporting grades back.
On the LMS side, you can usually set up either a JWKS URL or a public key for the tool connection.
If I set up the public key in pem format everything works fine. If instead I set up a JWKS URL, I get 400 errors when reporting a grade back to the LMS (tested with Avendoo and https://saltire.lti.app/platform).
My code is based on the Django example, I'm using the GET /jwks URL.
A call to GET /jwks seems to return a valid response that I can even transform to pem:
{
"keys": [
{
"e": "AQAB",
"kid": "bIaXjCAdqjr8ffH57teL4mRDam4KZOqmi7XvQe0n79c",
"kty": "RSA",
"n": "xWHS1QVRrHMAT9vmn5hQJ2nCoZ11CQSEv6b6tlIupKbyxCBRbk6Te094RsPcPgCaTHBE2TJ_mdQCqgiW5QPJCzPA2TKgjOvS7K8p9IM74imFJe8FkRlAFRF0JObrHWDS5Jw8f43ko3UjMHclGfP59uDN6IpEd8JDnssZA3wmzombTH8zgBpruvoi7W90pSaOnOVPYDdKn-KM2qcEaHnrvu53I5O0SHlBpkmBeiO6uoE1jcuv-qY_z1rlS9Y-xUHa1zWPnW4YpjGhOzSGxzZT5sTHGHXJdNtM2AoxgB3DvgMCr9xJ8LCOLCtJYqcV3yQ1SGHjJ4-IxYGCX0is_U-JhQ",
"alg": "RS256",
"use": "sig"
}
]
}
From the LMS side, I don't get many details on the error, just "invalid key".
Has anyone made this work or can point me to a way to get more details on the issue?
Just learned about this new spec that will make registration much easier — would be great to have support in this package. Thanks!
Grade allows to add a comment, but when the grade is sent it is converted to dictionary but the comment is left behind.
Here is where the grade is "serialised"
pylti1.3/pylti1p3/assignments_grades.py
Line 62 in 476556a
Line 168 in 476556a
When calling AssignmentsGradesService.get_lineitems() we're only getting the first 200 Line Items since D2L restricts to returning 200 line items.
from IMS docs:
limit - to restrict the number of line items returned; the platform MAY further reduce the number of items returned at its own discretion. If more items exist following the returned ones, a 'Link' http header (per [RFC8288]) with a URL pointing to the next page and a 'rel' value of 'next' MUST be included in the response; The platform MAY also include other relations including 'prev', 'first' and 'last'.
The URL for the 'next' link is left to the discretion of the implementer. The tool must use the 'next' URL as is and not re-apply filters to it. The tool platform must thus make sure the 'next' URL contains enough information to insure the next page displays the right set of elements based on the filters present on the original request.
Link: https://lms.example.com/sections/2923/lineitems/69?p=2; rel="next"
Heya,
First of all, thanks for making an awesome library!
I've been looking through the spec, and from our own usage, and I think deployment_id should be removed as a check in the JSON. The deployment ID is sent back via the JWT already, and isn't part of the signing. Having to go and check the deployment ID manually is going to be impossible for many vendors.
For example: We make an LTI and install it in over 200 courses. We would need to then get the autogenerated deployment ids from each course and add them to our JSON.
Faculty are also able to clone a course with our LTI installed. That will change the deployment id, but we won't know that, so even though it's a valid install, our application won't work for them.
I suggest removing the deployment_id check, and just adding the deployment_id from the JWT to a session variable.
While permforming a POST login request, i have observed another GET login request with all the post data as query params, Is there any other way to send the query params, I am asking this because our server have request max_length limitation and the query params causes issues.
This happens if .enable_check_cookies() is used for redirection.
Can we use this library to create an LTI consumer(like moodle or canvas)? If so then what are the steps to do?
Hi @dmitry-viskov ,
I was able to call get_members earlier but now I facing server error in both canvas and brightspace setup.
Here is the payload :
{'grant_type': 'client_credentials', 'client_assertion_type': 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer', 'scope': 'https://purl.imsglobal.org/spec/lti-nrps/scope/contextmembership.readonly', 'client_assertion': <jwt_val>'}
Error Message :
I'd like to see functions for is_instructor()
, is_teaching_assistant()
, is_learner()
, etc. It seems these need to be implemented in each application that supports LTI, so it would be nice to have them as part of this module.
HI @dmitry-viskov
I am doing "Ultra Proctoring Service Integration - IMS Proctoring Spec + UEF " with Blackboard. I have completed all the integration part but when i launch proctoring tool , I get https://purl.imsglobal.org/spec/lti/claim/message_type': 'LtiStartProctoring in the data and due to this getting pylti1p3.exception.LtiException: Unrecognized message type.
So can we add this type in recognised message type ?
Attaching document link as well screenshot
BB doc - https://docs.blackboard.com/lti/proctoring/ultra-proctoring-service-integration
IMS doc- https://www.imsglobal.org/spec/proctoring/v1p0
'
It would be nice if the MessageLaunch class had the ability to detect Transient LTI Launches.
The function could be something like message_launch.check_transient()
This is useful for detecting impersonation of users in Learning Management Systems. I believe the role claim comes through as "http://purl.imsglobal.org/vocab/lis/v2/institution/person#Transient"
.
IMS Information: http://www.imsglobal.org/lti-when-user-not-real-user
If this change is deemed valid, then I would be happy to help in implementing this change.
A declarative, efficient, and flexible JavaScript library for building user interfaces.
🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.
TypeScript is a superset of JavaScript that compiles to clean JavaScript output.
An Open Source Machine Learning Framework for Everyone
The Web framework for perfectionists with deadlines.
A PHP framework for web artisans
Bring data to life with SVG, Canvas and HTML. 📊📈🎉
JavaScript (JS) is a lightweight interpreted programming language with first-class functions.
Some thing interesting about web. New door for the world.
A server is a program made to process requests and deliver data to clients.
Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.
Some thing interesting about visualization, use data art
Some thing interesting about game, make everyone happy.
We are working to build community through open source technology. NB: members must have two-factor auth.
Open source projects and samples from Microsoft.
Google ❤️ Open Source for everyone.
Alibaba Open Source for everyone
Data-Driven Documents codes.
China tencent open source team.