Skip to content

Commit

Permalink
feat: LTI 1.3 reusable configuration (#2)
Browse files Browse the repository at this point in the history
Co-authored-by: Squirrel18 <[email protected]>
Co-authored-by: alexjmpb <[email protected]>
Co-authored-by: anfbermudezme <[email protected]>
Co-authored-by: sergiovalero20 <[email protected]>
  • Loading branch information
5 people authored Oct 24, 2023
1 parent 3c45570 commit a0d6c36
Show file tree
Hide file tree
Showing 9 changed files with 523 additions and 18 deletions.
12 changes: 10 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,16 @@ Now any changes made to the source code should reflect in the application

## Adding LTI Tools to the store

1. Go to `https://localhost:18010/admin`
2. Look for `LTI_STORE` and add **LTI Configurations** by clicking `+ Add` button
1. Go to `http://localhost:18000/admin`
2. Look for `LTI_STORE` and add **External lti configurations** by clicking `+ Add` button

## Use configuration on LTI consumer XBlock

1. Go to `http://localhost:18000/admin`
2. Look for `LTI_STORE` and go to **External lti configurations**
3. On the list of external LTI configurations, note down the "Filter Key" value
of the configuration to use (Example: `lti_store:1`).
4. Copy "Filter Key" to the "External ID" field on the LTI consumer XBlock.

## Linting

Expand Down
2 changes: 1 addition & 1 deletion lti_store/__init__.py
Original file line number Diff line number Diff line change
@@ -1 +1 @@
__version__ = "0.0.1"
__version__ = "1.0.0"
89 changes: 89 additions & 0 deletions lti_store/migrations/0002_add_lti_1p3_fields.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
# Generated by Django 3.2.17 on 2023-09-27 04:41

from django.db import migrations, models
import lti_store.models


class Migration(migrations.Migration):

dependencies = [
('lti_store', '0001_initial'),
]

operations = [
migrations.AddField(
model_name='externallticonfiguration',
name='lti_1p3_client_id',
field=models.CharField(blank=True, help_text='Client ID used by LTI tool', max_length=255, verbose_name='LTI 1.3 Client ID'),
),
migrations.AddField(
model_name='externallticonfiguration',
name='lti_1p3_deployment_id',
field=models.CharField(blank=True, help_text='Deployment ID used by LTI tool', max_length=255, verbose_name='LTI 1.3 Deployment ID'),
),
migrations.AddField(
model_name='externallticonfiguration',
name='lti_1p3_launch_url',
field=models.URLField(blank=True, help_text='This is the LTI launch URL, otherwise known as the target_link_uri.\n It represents the LTI resource to launch to or load in the second leg of the launch flow,\n when the resource is actually launched or loaded.', max_length=255, verbose_name='LTI 1.3 Launch URL'),
),
migrations.AddField(
model_name='externallticonfiguration',
name='lti_1p3_oidc_url',
field=models.URLField(blank=True, help_text='This is the OIDC third-party initiated login endpoint URL in the LTI 1.3 flow,\n which should be provided by the LTI Tool.', max_length=255, verbose_name='LTI 1.3 OIDC URL'),
),
migrations.AddField(
model_name='externallticonfiguration',
name='lti_1p3_private_key',
field=models.TextField(blank=True, help_text="Platform's generated Private key. Keep this value secret.", validators=[lti_store.models.validate_rsa_key], verbose_name='LTI 1.3 Private Key'),
),
migrations.AddField(
model_name='externallticonfiguration',
name='lti_1p3_private_key_id',
field=models.CharField(blank=True, help_text="Platform's generated Private key ID", max_length=255, verbose_name='LTI 1.3 Private Key ID'),
),
migrations.AddField(
model_name='externallticonfiguration',
name='lti_1p3_public_jwk',
field=models.JSONField(blank=True, default=dict, editable=False, help_text="Platform's generated JWK keyset.", verbose_name='LTI 1.3 Public JWK'),
),
migrations.AddField(
model_name='externallticonfiguration',
name='lti_1p3_redirect_uris',
field=models.TextField(blank=True, default=list, help_text="Valid urls the Tool may request us to redirect the id token to.\n The redirect uris are often the same as the launch url/deep linking url so if\n this field is empty, it will use them as the default. If you need to use different\n redirect uri's, enter them here. If you use this field you must enter all valid\n redirect uri's the tool may request.", validators=[lti_store.models.validate_list_field], verbose_name='LTI 1.3 Redirect URIs'),
),
migrations.AddField(
model_name='externallticonfiguration',
name='lti_1p3_tool_keyset_url',
field=models.URLField(blank=True, help_text="This is the LTI Tool's JWK (JSON Web Key)\n Keyset (JWKS) URL. This should be provided by the LTI\n Tool. One of either lti_1p3_tool_public_key or\n lti_1p3_tool_keyset_url must not be blank.", max_length=255, verbose_name='LTI 1.3 Tool Keyset URL'),
),
migrations.AddField(
model_name='externallticonfiguration',
name='lti_1p3_tool_public_key',
field=models.TextField(blank=True, help_text="This is the LTI Tool's public key.\n This should be provided by the LTI Tool.\n One of either lti_1p3_tool_public_key or\n lti_1p3_tool_keyset_url must not be blank.", validators=[lti_store.models.validate_rsa_key], verbose_name='LTI 1.3 Tool Public Key'),
),
migrations.AddField(
model_name='externallticonfiguration',
name='lti_advantage_ags_mode',
field=models.CharField(choices=[('disabled', 'Disabled'), ('declarative', 'Allow tools to submit grades only (declarative)'), ('programmatic', 'Allow tools to manage and submit grade (programmatic)')], default='declarative', help_text='Enable LTI Advantage Assignment and Grade Services and select the functionality\n enabled for LTI tools. The "declarative" mode (default) will provide a tool with a LineItem\n created from the XBlock settings, while the "programmatic" one will allow tools to manage,\n create and link the grades.', max_length=20, verbose_name='LTI Advantage Assignment and Grade Services Mode'),
),
migrations.AddField(
model_name='externallticonfiguration',
name='lti_advantage_deep_linking_enabled',
field=models.BooleanField(default=False, help_text='Enable LTI Advantage Deep Linking.', verbose_name='Enable LTI Advantage Deep Linking'),
),
migrations.AddField(
model_name='externallticonfiguration',
name='lti_advantage_deep_linking_launch_url',
field=models.URLField(blank=True, help_text='This is the LTI Advantage Deep Linking launch URL. If the LTI Tool\n does not provide one, use the same value as lti_1p3_launch_url.', max_length=255, verbose_name='LTI Advantage Deep Linking launch URL'),
),
migrations.AddField(
model_name='externallticonfiguration',
name='lti_advantage_enable_nrps',
field=models.BooleanField(default=False, help_text='Enable LTI Advantage Names and Role Provisioning Services.', verbose_name='Enable LTI Advantage Names and Role Provisioning Services'),
),
migrations.AlterField(
model_name='externallticonfiguration',
name='version',
field=models.CharField(choices=[('lti_1p1', 'LTI 1.1'), ('lti_1p3', 'LTI 1.3')], default='lti_1p1', max_length=10),
),
]
195 changes: 195 additions & 0 deletions lti_store/models.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,51 @@
import uuid
import json

from Cryptodome.PublicKey import RSA
from jwkest import jwk
from jwkest.jwk import RSAKey
from django.db import models
from django.core.exceptions import ValidationError
from django.utils.translation import gettext_lazy as _

MESSAGES = {
"required": _("This field is required."),
"required_pubkey_or_keyset": _("LTI 1.3 requires either a public key or a keyset URL."),
"invalid_rsa_key": _("Invalid RSA key format."),
"invalid_list_field": _('Should be a list (Example: ["id-1", "id-2", ...]).'),
}


def validate_rsa_key(key):
"""Validate RSA key format."""
try:
RSA.import_key(key)
except ValueError:
raise ValidationError(MESSAGES["invalid_rsa_key"])

return key


def validate_list_field(string):
"""Validate list field format."""
try:
deserialized = json.loads(string)
except ValueError:
raise ValidationError(MESSAGES["invalid_list_field"])

if not isinstance(deserialized, list):
raise ValidationError(MESSAGES["invalid_list_field"])


class LTIVersion(models.TextChoices):
LTI_1P1 = "lti_1p1", _("LTI 1.1")
LTI_1P3 = "lti_1p3", _("LTI 1.3")


class LTIAdvantageAGS(models.TextChoices):
DISABLED = "disabled", _("Disabled")
DECLARATIVE = "declarative", _("Allow tools to submit grades only (declarative)")
PROGRAMMATIC = "programmatic", _("Allow tools to manage and submit grade (programmatic)")


class ExternalLtiConfiguration(models.Model):
Expand Down Expand Up @@ -34,5 +76,158 @@ class ExternalLtiConfiguration(models.Model):
help_text=_("Client secret provided by the LTI tool provider."),
)

# LTI 1.3 Related variables
lti_1p3_client_id = models.CharField(
"LTI 1.3 Client ID",
max_length=255,
blank=True,
help_text=_("Client ID used by LTI tool"),
)
lti_1p3_deployment_id = models.CharField(
"LTI 1.3 Deployment ID",
max_length=255,
blank=True,
help_text=_("Deployment ID used by LTI tool"),
)
lti_1p3_oidc_url = models.URLField(
"LTI 1.3 OIDC URL",
max_length=255,
blank=True,
help_text=_("""This is the OIDC third-party initiated login endpoint URL in the LTI 1.3 flow,
which should be provided by the LTI Tool."""),
)
lti_1p3_launch_url = models.URLField(
"LTI 1.3 Launch URL",
max_length=255,
blank=True,
help_text=_("""This is the LTI launch URL, otherwise known as the target_link_uri.
It represents the LTI resource to launch to or load in the second leg of the launch flow,
when the resource is actually launched or loaded."""),
)
lti_1p3_private_key = models.TextField(
"LTI 1.3 Private Key",
blank=True,
help_text=_("Platform's generated Private key. Keep this value secret."),
validators=[validate_rsa_key],
)
lti_1p3_private_key_id = models.CharField(
"LTI 1.3 Private Key ID",
max_length=255,
blank=True,
help_text=_("Platform's generated Private key ID"),
)
lti_1p3_tool_public_key = models.TextField(
"LTI 1.3 Tool Public Key",
blank=True,
help_text=_("""This is the LTI Tool's public key.
This should be provided by the LTI Tool.
One of either lti_1p3_tool_public_key or
lti_1p3_tool_keyset_url must not be blank."""),
validators=[validate_rsa_key],
)
lti_1p3_tool_keyset_url = models.URLField(
"LTI 1.3 Tool Keyset URL",
max_length=255,
blank=True,
help_text=_("""This is the LTI Tool's JWK (JSON Web Key)
Keyset (JWKS) URL. This should be provided by the LTI
Tool. One of either lti_1p3_tool_public_key or
lti_1p3_tool_keyset_url must not be blank."""),
)
lti_1p3_redirect_uris = models.TextField(
"LTI 1.3 Redirect URIs",
default=list,
blank=True,
help_text=_("""Valid urls the Tool may request us to redirect the id token to.
The redirect uris are often the same as the launch url/deep linking url so if
this field is empty, it will use them as the default. If you need to use different
redirect uri's, enter them here. If you use this field you must enter all valid
redirect uri's the tool may request."""),
validators=[validate_list_field],
)
lti_1p3_public_jwk = models.JSONField(
"LTI 1.3 Public JWK",
default=dict,
blank=True,
editable=False,
help_text=_("Platform's generated JWK keyset."),
)

# LTI 1.3 Advantage Related Variables
lti_advantage_enable_nrps = models.BooleanField(
"Enable LTI Advantage Names and Role Provisioning Services",
default=False,
help_text=_("Enable LTI Advantage Names and Role Provisioning Services."),
)
lti_advantage_deep_linking_enabled = models.BooleanField(
"Enable LTI Advantage Deep Linking",
default=False,
help_text=_("Enable LTI Advantage Deep Linking."),
)
lti_advantage_deep_linking_launch_url = models.URLField(
"LTI Advantage Deep Linking launch URL",
max_length=255,
blank=True,
help_text=_("""This is the LTI Advantage Deep Linking launch URL. If the LTI Tool
does not provide one, use the same value as lti_1p3_launch_url."""),
)
lti_advantage_ags_mode = models.CharField(
"LTI Advantage Assignment and Grade Services Mode",
max_length=20,
choices=LTIAdvantageAGS.choices,
default=LTIAdvantageAGS.DECLARATIVE,
help_text=_("""Enable LTI Advantage Assignment and Grade Services and select the functionality
enabled for LTI tools. The "declarative" mode (default) will provide a tool with a LineItem
created from the XBlock settings, while the "programmatic" one will allow tools to manage,
create and link the grades.""")
)

def __str__(self):
return f"<ExternalLtiConfiguration #{self.id}: {self.slug}>"

def clean(self):
validation_errors = {}

if self.version == LTIVersion.LTI_1P1:
for field in [
"lti_1p1_launch_url",
"lti_1p1_client_key",
"lti_1p1_client_secret",
]:
# Raise ValidationError exception for any missing LTI 1.1 field.
if not getattr(self, field):
validation_errors.update({field: _(MESSAGES["required"])})

if self.version == LTIVersion.LTI_1P3:
if not self.lti_1p3_private_key:
# Raise ValidationError if private key is missing.
validation_errors.update(
{"lti_1p3_private_key": _(MESSAGES["required"])},
)
if not self.lti_1p3_tool_public_key and not self.lti_1p3_tool_keyset_url:
# Raise ValidationError if public key and keyset URL are missing.
validation_errors.update({
"lti_1p3_tool_public_key": MESSAGES["required_pubkey_or_keyset"],
"lti_1p3_tool_keyset_url": MESSAGES["required_pubkey_or_keyset"],
})

if validation_errors:
raise ValidationError(validation_errors)

def save(self, *args, **kwargs):
if self.version == LTIVersion.LTI_1P3:
# Generate client ID or private key ID if missing.
if not self.lti_1p3_client_id:
self.lti_1p3_client_id = str(uuid.uuid4())
if not self.lti_1p3_private_key_id:
self.lti_1p3_private_key_id = str(uuid.uuid4())

# Regenerate public JWK.
public_keys = jwk.KEYS()
public_keys.append(RSAKey(
kid=self.lti_1p3_private_key_id,
key=RSA.import_key(self.lti_1p3_private_key),
))
self.lti_1p3_public_jwk = json.loads(public_keys.dump_jwks())

super().save(*args, **kwargs)
Loading

0 comments on commit a0d6c36

Please sign in to comment.