Skip to content

Commit

Permalink
Merge ae5ffb5 into 2c62abd
Browse files Browse the repository at this point in the history
  • Loading branch information
giovannicimolin committed Jun 25, 2020
2 parents 2c62abd + ae5ffb5 commit 0c53173
Show file tree
Hide file tree
Showing 33 changed files with 2,591 additions and 46 deletions.
1 change: 1 addition & 0 deletions .coveragerc
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@
[run]
data_file = .coverage
source = lti_consumer
omit = */urls.py
53 changes: 53 additions & 0 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,15 @@ root folder:
$ pip install -r requirements/base.txt
Addtitionally, to enable LTI 1.3 Launch support, the following FEATURE flag needs to be set in `studio.yml`:

.. code:: yaml
FEATURES:
LTI_1P3_ENABLED: true
_Note: only LTI 1.3 launch is supported, and there's no implementation to pass back grades into the platform._

Installing in Docker Devstack
-----------------------------

Expand Down Expand Up @@ -55,6 +64,9 @@ advanced settings.
Testing Against an LTI Provider
-------------------------------

LTI 1.1
=======

http://lti.tools/saltire/ provides a "Test Tool Provider" service that allows
you to see messages sent by an LTI consumer.

Expand All @@ -76,6 +88,47 @@ http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/lat
tests for this repo running inside an LMS container). From here, you can see the contents of the
messages that we are sending as an LTI Consumer in the "Message Parameters" part of the "Message" tab.


LTI 1.3
=======

IMS Global provides a reference implementation of LTI 1.3 that can be used to test the XBlock.

On LTI 1.3 the authentication mechanism used is OAuth2 using the Client Credentials grant, this means
that to configure the tool, the LMS needs to know the Keyset URL or public key of the tool, and the tool
needs to know the LMS's one.

Intructions:

1. Set up a local tunnel tunneling the LMS (using `ngrok` or a similar tool) to get a URL accessible from the internet.
3. Create a new course, and add the `lti_consumer` block to the advanced modules list.
4. In the course, create a new unit and add the LTI block.
5. In studio, you'll see a few parameters being displayed in the preview:
```
Client: f0532860-cb34-47a9-b16c-53deb077d4de
Deployment ID: 1
# Note that these are LMS URLS
Keyset URL: http://localhost:18000/api/lti_consumer/v1/public_keysets/block-v1:OpenCraft+LTI101+2020_T2+type@lti_consumer+block@efc55c7abb87430883433bfafb83f054
OAuth Token URL: http://localhost:18000/api/lti_consumer/v1/token/block-v1:OpenCraft+LTI101+2020_T2+type@lti_consumer+block@efc55c7abb87430883433bfafb83f054
OIDC Callback URL: http://localhost:18000/api/lti_consumer/v1/launch/
```
6. Add the tunnel url to the keyset url as it'll need to be accessed by the tool (hosted externally).
```
# This is <LMS_URL>/api/lti_consumer/v1/launch/<BLOCK_LOCATION>
https://647dd2e1.ngrok.io/api/lti_consumer/v1/public_keysets/block-v1:OpenCraft+LTI101+2020_T2+type@lti_consumer+block@996c72b16070434098bc598bd7d6dbde
```
7. Set up a tool in the IMS Global reference implementation (https://lti-ri.imsglobal.org/lti/tools/).
* Click on __Add tool__ at the top of the page (https://lti-ri.imsglobal.org/lti/tools).
* Add the parameters and URLs provided by the block, and generate a private key on https://lti-ri.imsglobal.org/keygen/index and paste it there (don't close the tab, you'll need the public key later).
8. Go back to Studio, and edit the block adding it's settings (you'll find them by scrolling down https://lti-ri.imsglobal.org/lti/tools/ until you find the tool you just created):
```
Tool launch URL: https://lti-ri.imsglobal.org/lti/tools/[tool_id]/launches
Tool OIDC Login Initiation URL: https://lti-ri.imsglobal.org/lti/tools/[tool_id]/login_initiations
Tool public key: Public key from key page.
```
8. Publish block, log into LMS and navigate to the LTI block page.
9. Check that the LTI launch was successful.

Custom LTI Parameters
---------------------
This XBlock sends a number of parameters to the provider including some optional parameters. To keep the XBlock
Expand Down
1 change: 1 addition & 0 deletions lti_consumer/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@
Runtime will load the XBlock class from here.
"""
from .lti_consumer import LtiConsumerXBlock
from .apps import LTIConsumerApp
27 changes: 27 additions & 0 deletions lti_consumer/apps.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# -*- coding: utf-8 -*-
"""
lti_consumer Django application initialization.
"""
from __future__ import absolute_import, unicode_literals

from django.apps import AppConfig


class LTIConsumerApp(AppConfig):
"""
Configuration for the lti_consumer Django application.
"""

name = 'lti_consumer'

# Set LMS urls for LTI endpoints
# Urls are under /api/lti_consumer/
plugin_app = {
'url_config': {
'lms.djangoapp': {
'namespace': 'lti_consumer',
'regex': '^api/',
'relative_path': 'plugin.urls',
}
}
}
122 changes: 122 additions & 0 deletions lti_consumer/lti_1p3/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
# LTI 1.3 Consumer Class

This implements a LTI 1.3 compliant consumer class which is request agnostic and can
be reused in different contexts (XBlock, Django plugin, and even on other frameworks).

This doesn't implement any data storage, just the methods required for handling LTI messages
and Access Tokens.

Features:
- LTI 1.3 Launch with full OIDC flow
- Support for custom parameters claim
- Support for launch presentation claim
- Access token creation

This implementation was based on the following IMS Global Documents:
- LTI 1.3 Core Specification: http://www.imsglobal.org/spec/lti/v1p3/
- IMS Global Security Framework: https://www.imsglobal.org/spec/security/v1p0/


## Using this class

To perform LTI launches, you'll need to store and manage a few resources and endpoints.

### Data storage

LTI variables from tool:
* **lti_oidc_url**: The tool's OIDC login initiation URL, needs to be stored and passed to the consumer every time it's instanced.
* **lti_launch_url**: The tool's LTI launch URL, where the platform submit's the actual LTI launch request with the signed LTI message.
* **tool_key**: The tool's public key, in raw PEM format. This will be used to verify message signatures from the tool to the platform. This is not required if this module is just used to launch LTI tools (without LTI advantage support).

LTI configuration from platform:
* **client_id**: Tool specific client ID, to separate multiple tool configurations (can be the same as the `rsa_key_id`).
* **deployment_id**: Deployment specific key ID ([spec reference](http://www.imsglobal.org/spec/lti/v1p3/#tool-deployment)). Used if deploying multi-tenant instances of LTI consumer, otherwise just a fixed string known be the tool.
* **rsa_key**: a raw PEM export of a RSA key. The minimum required is a SHA-256 (RS256) key (and also recommended to maximize interoperability).
* **rsa_key_id**: the key id for the RSA key above. Should be unique for every key used for LTI platform wide to avoid signature validation issues.

### Endpoints

To run LTI launches, 2 endpoints are required:
* **Launch Callback URL:** URL in the platform that the Tool will redirect to with response variables from preflight request made to OIDC endpoints.
* **Keyset URL:** URL that the tool will use to fetch the public key of the platform (a [JWK as defined in RFC7517](https://tools.ietf.org/html/rfc7517)). This URL should be publicly accessible and return the contents of the `get_public_keyset` function as a JSON.

### Example implementation

Here's a example LTI Launch using Django in the edX platform.

```python
def _get_lti1p3_consumer():
"""
Returns an configured instance of LTI consumer.
"""
return LtiConsumer1p3(
# Tool urls
lti_oidc_url=lti_1p3_oidc_url,
lti_launch_url=lti_1p3_launch_url,
# Platform and deployment configuration
iss=get_lms_base(),
client_id=lti_1p3_client_id,
deployment_id="1",
# Platform key
rsa_key=lti_1p3_block_key,
rsa_key_id=lti_1p3_client_id,
# Tool key
tool_key=lti_1p3_tool_public_key,
)


def public_keyset(request):
"""
Return LTI Public Keyset url.
This endpoint must be configured in the tool.
"""
return JsonResponse(
_get_lti1p3_consumer().get_public_keyset(),
content_type='application/json'
)

def lti_preflight_request(request):
"""
Endpoint that'll render the initial OIDC authorization request form
and submit it to the tool.
The platform needs to know the tool OIDC endpoint.
"""
lti_consumer = _get_lti1p3_consumer()
context = lti_consumer.prepare_preflight_url(
callback_url=get_lms_lti_launch_link()
)

# This template should render a simple redirection to the URL
# provided by the context through the `oidc_url` key above.
# This can also be a redirect.
return render(request, 'templates/lti_1p3_oidc.html', context)

def lti_launch_endpoint(request):
"""
Platform endpoint that'll receive OIDC login request variables and generate launch request.
"""
lti_consumer = _get_lti1p3_consumer()
context = {}

# Required user claim data
lti_consumer.set_user_data(
user_id=request.user,
# Pass django user role to library
role='student'
)

context.update({
"preflight_response": dict(request.GET),
"launch_request": lti_consumer.generate_launch_request(
resource_link=self.resource_link_id,
preflight_response=request.GET
)
})

context.update({'launch_url': self.lti_1p3_launch_url})
# This template should render a form, and then submit it to the tool's launch URL, as
# described in http://www.imsglobal.org/spec/lti/v1p3/#lti-message-general-details
return render(request, 'templates/lti_launch_request_form.html', context)
```
Empty file.
45 changes: 45 additions & 0 deletions lti_consumer/lti_1p3/constants.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
"""
LTI 1.3 Constants definition file
This includes the LTI Base message, OAuth2 scopes, and
lists of required and optional parameters required for
LTI message generation and validation.
"""

LTI_BASE_MESSAGE = {
# Claim type: fixed key with value `LtiResourceLinkRequest`
# http://www.imsglobal.org/spec/lti/v1p3/#message-type-claim
"https://purl.imsglobal.org/spec/lti/claim/message_type": "LtiResourceLinkRequest",

# LTI Claim version
# http://www.imsglobal.org/spec/lti/v1p3/#lti-version-claim
"https://purl.imsglobal.org/spec/lti/claim/version": "1.3.0",
}

LTI_1P3_ROLE_MAP = {
'staff': [
'http://purl.imsglobal.org/vocab/lis/v2/system/person#Administrator',
'http://purl.imsglobal.org/vocab/lis/v2/institution/person#Instructor',
'http://purl.imsglobal.org/vocab/lis/v2/institution/person#Student',
],
'instructor': [
'http://purl.imsglobal.org/vocab/lis/v2/institution/person#Instructor',
'http://purl.imsglobal.org/vocab/lis/v2/institution/person#Student'
],
'student': [
'http://purl.imsglobal.org/vocab/lis/v2/institution/person#Student'
],
'guest': [
'http://purl.imsglobal.org/vocab/lis/v2/institution/person#Student'
],
}


LTI_1P3_ACCESS_TOKEN_REQUIRED_CLAIMS = set([
"grant_type",
"client_assertion_type",
"client_assertion",
"scope",
])

LTI_1P3_ACCESS_TOKEN_SCOPES = []
Loading

0 comments on commit 0c53173

Please sign in to comment.