diff --git a/README.md b/README.md index 32f8e48..7287c11 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/lti_store/__init__.py b/lti_store/__init__.py index f102a9c..5becc17 100644 --- a/lti_store/__init__.py +++ b/lti_store/__init__.py @@ -1 +1 @@ -__version__ = "0.0.1" +__version__ = "1.0.0" diff --git a/lti_store/migrations/0002_add_lti_1p3_fields.py b/lti_store/migrations/0002_add_lti_1p3_fields.py new file mode 100644 index 0000000..6279355 --- /dev/null +++ b/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), + ), + ] diff --git a/lti_store/models.py b/lti_store/models.py index 5dcefc3..fdea97c 100644 --- a/lti_store/models.py +++ b/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): @@ -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"" + + 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) diff --git a/lti_store/tests/test_models.py b/lti_store/tests/test_models.py index 3bcef10..7db0e72 100644 --- a/lti_store/tests/test_models.py +++ b/lti_store/tests/test_models.py @@ -1,11 +1,179 @@ +from unittest.mock import patch, call + +from ddt import ddt, data +from Cryptodome.PublicKey import RSA +from django.core.exceptions import ValidationError from django.test import TestCase -from lti_store.models import ExternalLtiConfiguration, LTIVersion +from lti_store.models import ExternalLtiConfiguration, LTIVersion, MESSAGES +@ddt class LTIConfigurationTestCase(TestCase): + + REQUIRED_FIELDS = { + "name": "Test Config", + "slug": "test-config", + } + UUID4 = "test-uuid4" + KEY_OBJ = RSA.generate(2048) + PRIVATE_KEY = KEY_OBJ.exportKey().decode() + PUBLIC_KEY = KEY_OBJ.publickey().exportKey().decode() + PUBLIC_JWK = "test-public-jwk" + def test_string_representation_of_model(self): - cfg = ExternalLtiConfiguration.objects.create( - name="Test Config", slug="test-config" + config = ExternalLtiConfiguration.objects.create(**self.REQUIRED_FIELDS) + self.assertEqual( + str(config), + f"", ) - self.assertEqual(str(cfg), "") - cfg.delete() + + def test_1p1_missing_fields(self): + """Test clean method on a LTI 1.1 configuration with missing fields.""" + with self.assertRaises(ValidationError) as exc: + ExternalLtiConfiguration( + **self.REQUIRED_FIELDS, + version=LTIVersion.LTI_1P1, + ).clean() + + self.assertEqual( + str(exc.exception), + str( + { + "lti_1p1_launch_url": [MESSAGES["required"]], + "lti_1p1_client_key": [MESSAGES["required"]], + "lti_1p1_client_secret": [MESSAGES["required"]], + }, + ), + ) + + def test_1p3_missing_private_key(self): + """Test clean method on a LTI 1.3 configuration with missing private key.""" + with self.assertRaises(ValidationError) as exc: + ExternalLtiConfiguration( + **self.REQUIRED_FIELDS, + version=LTIVersion.LTI_1P3, + lti_1p3_tool_public_key=self.PUBLIC_KEY, + ).clean() + + self.assertEqual( + str(exc.exception), + str( + { + "lti_1p3_private_key": [MESSAGES["required"]], + }, + ), + ) + + def test_1p3_invalid_private_key(self): + """Test clean method on a LTI 1.3 configuration with invalid private key.""" + with self.assertRaises(ValidationError) as exc: + ExternalLtiConfiguration( + **self.REQUIRED_FIELDS, + version=LTIVersion.LTI_1P3, + lti_1p3_private_key="invalid-private-key", + lti_1p3_tool_public_key=self.PUBLIC_KEY, + ).full_clean() + + self.assertEqual( + str(exc.exception), + str( + { + "lti_1p3_private_key": [MESSAGES["invalid_rsa_key"]], + }, + ), + ) + + def test_1p3_invalid_tool_public_key(self): + """Test clean method on a LTI 1.3 configuration with invalid tool public key.""" + with self.assertRaises(ValidationError) as exc: + ExternalLtiConfiguration( + **self.REQUIRED_FIELDS, + version=LTIVersion.LTI_1P3, + lti_1p3_private_key=self.PRIVATE_KEY, + lti_1p3_tool_public_key="invalid-public-key", + ).full_clean() + + self.assertEqual( + str(exc.exception), + str( + { + "lti_1p3_tool_public_key": [MESSAGES["invalid_rsa_key"]], + }, + ), + ) + + @data("invalid-redirect-uris", '{"test": "test"}') + def test_1p3_invalid_redirect_uris(self, value): + """Test clean method on a LTI 1.3 configuration with invalid redirect URIs.""" + with self.assertRaises(ValidationError) as exc: + ExternalLtiConfiguration( + **self.REQUIRED_FIELDS, + version=LTIVersion.LTI_1P3, + lti_1p3_private_key=self.PRIVATE_KEY, + lti_1p3_tool_public_key=self.PUBLIC_KEY, + lti_1p3_redirect_uris=value, + ).full_clean() + + self.assertEqual( + str(exc.exception), + str( + { + "lti_1p3_redirect_uris": [MESSAGES["invalid_list_field"]], + }, + ), + ) + + def test_1p3_missing_public_key_and_keyset_url(self): + """Test clean method on a LTI 1.3 configuration with missing public key or keyset URL.""" + with self.assertRaises(ValidationError) as exc: + ExternalLtiConfiguration( + **self.REQUIRED_FIELDS, + version=LTIVersion.LTI_1P3, + lti_1p3_private_key=self.PRIVATE_KEY, + ).clean() + + self.assertEqual( + str(exc.exception), + str( + { + "lti_1p3_tool_public_key": [MESSAGES["required_pubkey_or_keyset"]], + "lti_1p3_tool_keyset_url": [MESSAGES["required_pubkey_or_keyset"]], + }, + ), + ) + + @patch("lti_store.models.json.loads") + @patch.object(RSA, "import_key") + @patch("lti_store.models.RSAKey") + @patch("lti_store.models.jwk.KEYS") + @patch("lti_store.models.uuid.uuid4") + def test_1p3_save( + self, + uuid4_mock, + keys_mock, + rsakey_mock, + rsa_import_key_mock, + loads_mock, + ): + """Test save method on a LTI 1.3 configuration.""" + uuid4_mock.return_value = self.UUID4 + loads_mock.return_value = self.PUBLIC_JWK + + config = ExternalLtiConfiguration( + **self.REQUIRED_FIELDS, + version=LTIVersion.LTI_1P3, + lti_1p3_private_key=self.PRIVATE_KEY, + lti_1p3_tool_public_key=self.PUBLIC_KEY, + ) + config.save() + + self.assertEqual(config.lti_1p3_client_id, self.UUID4) + self.assertEqual(config.lti_1p3_private_key_id, self.UUID4) + self.assertEqual(config.lti_1p3_public_jwk, self.PUBLIC_JWK) + uuid4_mock.assert_has_calls([call(), call()]) + keys_mock.assert_called_once_with() + rsa_import_key_mock.assert_called_once_with(self.PRIVATE_KEY) + rsakey_mock.assert_called_once_with(kid=self.UUID4, key=rsa_import_key_mock()) + keys_mock().append.assert_called_once_with(rsakey_mock()) + keys_mock().dump_jwks.assert_called_once_with() + loads_mock.assert_called_once_with(keys_mock().dump_jwks()) diff --git a/requirements/base.in b/requirements/base.in index 2588d45..f8e1d61 100644 --- a/requirements/base.in +++ b/requirements/base.in @@ -1,2 +1,4 @@ django<4.0 openedx-filters +pycryptodomex +pyjwkest diff --git a/requirements/base.txt b/requirements/base.txt index 46ff2a5..a550a4d 100644 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -6,13 +6,33 @@ # asgiref==3.5.0 # via django -django==3.2.12 +certifi==2023.5.7 + # via requests +charset-normalizer==3.1.0 + # via requests +django==3.2.19 # via # -r requirements/base.in # openedx-filters -openedx-filters==0.5.0 +future==0.18.3 + # via pyjwkest +idna==3.4 + # via requests +openedx-filters==1.2.0 # via -r requirements/base.in -pytz==2021.3 +pycryptodomex==3.17 + # via + # -r requirements/base.in + # pyjwkest +pyjwkest==1.4.2 + # via -r requirements/base.in +pytz==2023.3 # via django -sqlparse==0.4.2 +requests==2.30.0 + # via pyjwkest +six==1.16.0 + # via pyjwkest +sqlparse==0.4.4 # via django +urllib3==2.0.2 + # via requests diff --git a/requirements/dev.in b/requirements/dev.in index 028fe43..612659d 100644 --- a/requirements/dev.in +++ b/requirements/dev.in @@ -2,4 +2,5 @@ pytest pytest-cov pytest-django -black \ No newline at end of file +black +ddt diff --git a/requirements/dev.txt b/requirements/dev.txt index 58a9fcb..46c4371 100644 --- a/requirements/dev.txt +++ b/requirements/dev.txt @@ -10,11 +10,17 @@ attrs==21.4.0 # via pytest black==22.1.0 # via -r requirements/dev.in +certifi==2023.5.7 + # via requests +charset-normalizer==3.1.0 + # via requests click==8.0.4 # via black coverage[toml]==6.3.2 # via pytest-cov -django==3.2.12 +ddt==1.6.0 + # via -r requirements/dev.in +django==3.2.19 # via # -r requirements/base.in # openedx-filters @@ -22,16 +28,26 @@ iniconfig==1.1.1 # via pytest mypy-extensions==0.4.3 # via black -openedx-filters==0.5.0 - # via -r requirements/base.in +future==0.18.3 + # via pyjwkest +idna==3.4 + # via requests packaging==21.3 # via pytest pathspec==0.9.0 # via black +openedx-filters==1.2.0 + # via -r requirements/base.in platformdirs==2.5.1 # via black pluggy==1.0.0 # via pytest +pycryptodomex==3.17 + # via + # -r requirements/base.in + # pyjwkest +pyjwkest==1.4.2 + # via -r requirements/base.in py==1.11.0 # via pytest pyparsing==3.0.7 @@ -45,9 +61,13 @@ pytest-cov==3.0.0 # via -r requirements/dev.in pytest-django==4.5.2 # via -r requirements/dev.in -pytz==2021.3 +pytz==2023.3 # via django -sqlparse==0.4.2 +requests==2.30.0 + # via pyjwkest +six==1.16.0 + # via pyjwkest +sqlparse==0.4.4 # via django tomli==2.0.1 # via @@ -56,3 +76,5 @@ tomli==2.0.1 # pytest typing-extensions==4.1.1 # via black +urllib3==2.0.2 + # via requests