Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: LTI 1.3 reusable configuration #2

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
12 changes: 10 additions & 2 deletions README.md
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
@@ -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
@@ -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
@@ -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())
Comment on lines +225 to +231
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do we want to regenerate JWK on every save? The LtiConfiguration model is doing this only when the value is missing.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is only executed when the external configuration is saved, I don't think there is much of a performance hit compared to verifying if any key is missing every time they are being accessed. The method used on the LtiConfiguration has a disadvantage, if the LtiConfiguration private key is updated, the public JWK will need to be deleted so it's regenerated. I could add more logic to make sure the public JWK is only re-generated if the private key or private key ID is changed, but I didn't find a simple way to do this on the model save method.


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