From 9b054ee5eeb84fc91ac3bd84b83bfbb598041273 Mon Sep 17 00:00:00 2001 From: Colin Murtaugh Date: Thu, 31 Oct 2024 16:32:06 -0400 Subject: [PATCH] initial commit --- .github/workflows/workflow.yml | 67 ++++++ .gitignore | 186 +++++++++++++++ README.md | 4 +- lti_dynamic_registration/__init__.py | 0 lti_dynamic_registration/constants.py | 94 ++++++++ lti_dynamic_registration/types.py | 312 ++++++++++++++++++++++++++ lti_dynamic_registration/views.py | 0 pyproject.toml | 38 ++++ 8 files changed, 700 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/workflow.yml create mode 100644 .gitignore create mode 100644 lti_dynamic_registration/__init__.py create mode 100644 lti_dynamic_registration/constants.py create mode 100644 lti_dynamic_registration/types.py create mode 100644 lti_dynamic_registration/views.py create mode 100644 pyproject.toml diff --git a/.github/workflows/workflow.yml b/.github/workflows/workflow.yml new file mode 100644 index 0000000..03e138e --- /dev/null +++ b/.github/workflows/workflow.yml @@ -0,0 +1,67 @@ +name: Publish Python 🐍 distribution 📦 to PyPI and TestPyPI + +on: push + +jobs: + build: + name: Build distribution 📦 + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.x" + + - name: Install pypa/build + run: >- + python3 -m + pip install + build + --user + - name: Build a binary wheel and a source tarball + run: python3 -m build + - name: Store the distribution packages + uses: actions/upload-artifact@v4 + with: + name: python-package-distributions + path: dist/ + + publish-to-pypi: + name: >- + Publish Python 🐍 distribution 📦 to PyPI + if: startsWith(github.ref, 'refs/tags/') # only publish to PyPI on tag pushes + needs: + - build + runs-on: ubuntu-latest + environment: + name: pypi + url: https://pypi.org/p/django-lti-dynamic-registration + permissions: + id-token: write # IMPORTANT: mandatory for trusted publishing + + + publish-to-testpypi: + name: Publish Python 🐍 distribution 📦 to TestPyPI + needs: + - build + runs-on: ubuntu-latest + + environment: + name: testpypi + url: https://test.pypi.org/p/django-lti-dynamic-registration + + permissions: + id-token: write # IMPORTANT: mandatory for trusted publishing + + steps: + - name: Download all the dists + uses: actions/download-artifact@v4 + with: + name: python-package-distributions + path: dist/ + - name: Publish distribution 📦 to TestPyPI + uses: pypa/gh-action-pypi-publish@release/v1 + with: + repository-url: https://test.pypi.org/legacy/ \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6a73a2f --- /dev/null +++ b/.gitignore @@ -0,0 +1,186 @@ +# MacOS + +# General +.DS_Store +.AppleDouble +.LSOverride + +# Icon must end with two \r +Icon + +# Thumbnails +._* + +# Files that might appear in the root of a volume +.DocumentRevisions-V100 +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes +.VolumeIcon.icns +.com.apple.timemachine.donotpresent + +# Directories potentially created on remote AFP share +.AppleDB +.AppleDesktop +Network Trash Folder +Temporary Items +.apdisk + + +# VS Code + +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +*.code-workspace + +# Local History for Visual Studio Code +.history/ + + +# Python + +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# Local only files +.local/ \ No newline at end of file diff --git a/README.md b/README.md index 268419c..0fe7b57 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,4 @@ # django-lti-dynamic-registration -Add-on to django-lti to support dynamic registration. +Add-on to django-lti to support dynamic registration. + +See: https://www.imsglobal.org/spec/lti-dr/v1p0 diff --git a/lti_dynamic_registration/__init__.py b/lti_dynamic_registration/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/lti_dynamic_registration/constants.py b/lti_dynamic_registration/constants.py new file mode 100644 index 0000000..b64db35 --- /dev/null +++ b/lti_dynamic_registration/constants.py @@ -0,0 +1,94 @@ +import requests +from django.http import HttpResponse +from django.views import View +from lti_tool.models import LtiRegistration +from .types import LmsLtiRegistration + + +class DynamicRegistrationBaseView(View): + + tool_friendly_name: str = "Override this in your subclass" + + def get(self, request, *args, **kwargs): + # handle get requests here + raise NotImplementedError( + "Subclasses of DynamicRegistrationBaseView must implement get" + ) + + def post(self, request, *args, **kwargs): + # handle post requests here + raise NotImplementedError( + "Subclasses of DynamicRegistrationBaseView must implement post" + ) + + def get_openid_config(self) -> dict: + openid_configuration_url = self.request.GET.get("openid_configuration") + registration_token = self.request.GET.get("registration_token") + + if not openid_configuration_url or not registration_token: + raise ValueError( + "openid_configuration_url and registration_token are required (this view must be accessed from within a dynamic registration flow)" + ) + + headers = {"Authorization": f"Bearer {registration_token}"} + response = requests.get(openid_configuration_url, headers=headers) + response.raise_for_status() + openid_config = response.json() + + # make sure that the openid_configuration_url starts with the issuer from the openid_config + if not openid_configuration_url.startswith(openid_config["issuer"]): + raise ValueError( + "invalid openid_configuration_url: does not match the issuer in the openid config" + ) + return openid_config + + def register_tool_in_platform( + self, + openid_config: dict, + tool_platform_registration: LmsLtiRegistration, + ) -> str: + + registration_token = self.request.GET.get("registration_token") + + response = requests.post( + openid_config["registration_endpoint"], + json=tool_platform_registration.to_dict(), + headers={ + "Authorization": f"Bearer {registration_token}", + "Content-Type": "application/json", + }, + ) + response.raise_for_status() + + response_data = response.json() + client_id = response_data["client_id"] + return client_id + + def register_platform_in_tool( + self, consumer_name: str, openid_config: dict + ) -> LtiRegistration: + reg = LtiRegistration( + name=consumer_name, + issuer=openid_config["issuer"], + auth_url=openid_config["authorization_endpoint"], + token_url=openid_config["token_endpoint"], + keyset_url=openid_config["jwks_uri"], + ) + reg.save() + return reg + + def success_response(self) -> HttpResponse: + return HttpResponse( + """ + + + Dynamic Registration Successful + + + + + + """ + ) diff --git a/lti_dynamic_registration/types.py b/lti_dynamic_registration/types.py new file mode 100644 index 0000000..302fcaf --- /dev/null +++ b/lti_dynamic_registration/types.py @@ -0,0 +1,312 @@ +import json +from enum import Enum +from typing import Any, Dict, List, Optional, Sequence + + +class CanvasVisibility(str, Enum): + PUBLIC = "public" + ADMINS = "admins" + MEMBERS = "members" + + def __str__(self) -> str: + return self.value + + +class CanvasPrivacyLevel(str, Enum): + ANONYMOUS = "anonymous" + NAME_ONLY = "name_only" + EMAIL_ONLY = "email_only" + PUBLIC = "public" + + def __str__(self) -> str: + return self.value + + +# https://www.imsglobal.org/spec/lti-dr/v1p0#lti-message +class LtiMessage: + def __init__( + self, + type: str, + icon_uri: Optional[str] = None, + label: Optional[str] = None, + placements: Optional[List[str]] = None, + target_link_uri: Optional[str] = None, + custom_parameters: Optional[Dict[str, str]] = None, + roles: Optional[List[str]] = None, + ): + self.type = type + self.icon_uri = icon_uri + self.label = label + self.placements = placements + self.target_link_uri = target_link_uri + self.custom_parameters = custom_parameters + self.roles = roles + + def to_dict(self) -> Dict[str, Any]: + # prune empty values from the dictionary + return {k: v for k, v in self.__dict__ if v} + + def __str__(self) -> str: + return json.dumps(self.to_dict(), indent=2) + + +# https://canvas.instructure.com/doc/api/file.registration.html (LTI Message schema section) +class CanvasLtiMessage(LtiMessage): + + def __init__( + self, + type: str, + icon_uri: Optional[str] = None, + label: Optional[str] = None, + placements: Optional[List[str]] = None, + target_link_uri: Optional[str] = None, + custom_parameters: Optional[List[str]] = None, + permissions: Optional[List[str]] = None, + roles: Optional[List[str]] = None, + display_type: Optional[str] = None, + default_enabled: Optional[bool] = None, + visibility: Optional[CanvasVisibility] = None, + ): + super().__init__( + type=type, + icon_uri=icon_uri, + label=label, + placements=placements, + target_link_uri=target_link_uri, + custom_parameters=CanvasLtiRegistration.format_custom_params( + custom_params=custom_parameters, permissions=permissions + ), + roles=roles, + ) + self.display_type = display_type + self.default_enabled = default_enabled + self.visibility = visibility + + def to_dict(self) -> Dict[str, Any]: + new_dict = { + "type": self.type, + "icon_uri": self.icon_uri, + "label": self.label, + "placements": self.placements, + "target_link_uri": self.target_link_uri, + "custom_parameters": self.custom_parameters, + "roles": self.roles, + "https://canvas.instructure.com/lti/display_type": self.display_type, + "https://canvas.instructure.com/lti/course_navigation/default_enabled": self.default_enabled, + "https://canvas.instructure.com/lti/visibility": self.visibility, + } + + # prune empty values from the dictionary + return {k: v for k, v in new_dict.items() if v} + + def __str__(self) -> str: + return json.dumps(self.to_dict(), indent=2) + + +# https://www.imsglobal.org/spec/lti-dr/v1p0#lti-configuration-0 +class LmsLtiToolConfiguration: + """ + Represents the LTI configuration for a tool in an LMS. + """ + + def __init__( + self, + domain: str, + target_link_uri: str, + claims: List[str], + messages: Sequence[LtiMessage], + custom_parameters: Optional[Dict[str, str]] = None, + secondary_domains: Optional[List[str]] = None, + description: Optional[str] = None, + ): + self.claims = claims + self.custom_parameters = custom_parameters + self.domain = domain + self.messages = messages + self.target_link_uri = target_link_uri + self.secondary_domains = secondary_domains + self.description = description + + def to_dict(self) -> Dict[str, Any]: + new_dict = { + "claims": self.claims, + "custom_parameters": self.custom_parameters, + "domain": self.domain, + "messages": [message.to_dict() for message in self.messages], + "target_link_uri": self.target_link_uri, + "secondary_domains": self.secondary_domains, + "description": self.description, + } + + # prune empty values from the dictionary + return {k: v for k, v in new_dict.items() if v} + + +# https://canvas.instructure.com/doc/api/file.registration.html (LTI Configuration schema section) +class CanvasLtiToolConfiguration(LmsLtiToolConfiguration): + """ + Represents the LTI configuration for a tool in Canvas. + """ + + def __init__( + self, + domain: str, + target_link_uri: str, + claims: List[str], + messages: Sequence[CanvasLtiMessage], + custom_parameters: Optional[List[str]] = None, + permissions: Optional[List[str]] = None, + secondary_domains: Optional[List[str]] = None, + description: Optional[str] = None, + privacy_level: Optional[CanvasPrivacyLevel] = None, + tool_id: Optional[str] = None, + ): + super().__init__( + domain=domain, + target_link_uri=target_link_uri, + claims=claims, + messages=messages, + custom_parameters=CanvasLtiRegistration.format_custom_params( + custom_params=custom_parameters, permissions=permissions + ), + secondary_domains=secondary_domains, + description=description, + ) + self.privacy_level = privacy_level + self.tool_id = tool_id + + def to_dict(self) -> Dict[str, Any]: + new_dict = { + "claims": self.claims, + "custom_parameters": self.custom_parameters, + "domain": self.domain, + "messages": [message.to_dict() for message in self.messages], + "target_link_uri": self.target_link_uri, + "secondary_domains": self.secondary_domains, + "description": self.description, + "https://canvas.instructure.com/lti/privacy_level": self.privacy_level, + "https://canvas.instructure.com/lti/tool_id": self.tool_id, + } + + # prune empty values from the dictionary + return {k: v for k, v in new_dict.items() if v} + + +# https://www.imsglobal.org/spec/lti-dr/v1p0#openid-configuration-0 +class LmsLtiRegistration: + """ + Represents a tool registration in an LMS. + """ + + def __init__( + self, + client_name: str, + jwks_uri: str, + initiate_login_uri: str, + target_link_uri: str, + scopes: List[str], + lti_tool_configuration: LmsLtiToolConfiguration, + ): + self.application_type = "web" + self.grant_types = ["client_credentials", "implicit"] + self.initiate_login_uri = initiate_login_uri + self.redirect_uris = [target_link_uri] + self.response_types = ["id_token"] + self.client_name = client_name + self.jwks_uri = jwks_uri + self.token_endpoint_auth_method = "private_key_jwt" + self.scopes = scopes + self.lti_tool_configuration = lti_tool_configuration + + def to_dict(self) -> Dict[str, Any]: + new_dict = { + "application_type": self.application_type, + "grant_types": self.grant_types, + "initiate_login_uri": self.initiate_login_uri, + "redirect_uris": self.redirect_uris, + "response_types": self.response_types, + "client_name": self.client_name, + "jwks_uri": self.jwks_uri, + "token_endpoint_auth_method": self.token_endpoint_auth_method, + "scope": " ".join(self.scopes), + "https://purl.imsglobal.org/spec/lti-tool-configuration": self.lti_tool_configuration.to_dict(), + } + + # prune empty values from the dictionary + return {k: v for k, v in new_dict.items() if v} + + def __str__(self) -> str: + return json.dumps(self.to_dict(), indent=2) + + +# https://canvas.instructure.com/doc/api/file.registration.html (LTI Registration schema section) +class CanvasLtiRegistration(LmsLtiRegistration): + """ + Represents a tool registration in Canvas. + """ + + def __init__( + self, + client_name: str, + jwks_uri: str, + initiate_login_uri: str, + target_link_uri: str, + scopes: List[str], + lti_tool_configuration: CanvasLtiToolConfiguration, + ): + super().__init__( + client_name=client_name, + jwks_uri=jwks_uri, + initiate_login_uri=initiate_login_uri, + target_link_uri=target_link_uri, + scopes=scopes, + lti_tool_configuration=lti_tool_configuration, + ) + + @staticmethod + def format_custom_params( + custom_params: Optional[List[str]], permissions: Optional[List[str]] = None + ) -> Dict[str, str]: + """ + This function takes a list of custom parameters as provided by the platform and + converts them into a dictionary that can be used to populate the custom_parameters field. + The dictionary keys are converted to snake_case and the values are the original custom + parameter names prefixed with a "$" character. + + Example input: + [ + "Canvas.user.sisIntegrationId", + "Canvas.course.sectionIds", + "Canvas.group.contextIds", + "Canvas.xapi.url", + "Caliper.url", + ] + + Example output: + { + "canvas_user_sisintegrationid": "$Canvas.user.sisIntegrationId", + "canvas_course_sectionids": "$Canvas.course.sectionIds", + "canvas_group_contextids": "$Canvas.group.contextIds", + "canvas_xapi_url": "$Canvas.xapi.url", + "caliper_url": "$Caliper.url", + } + """ + if custom_params is None: + return {} + + custom_params_dict = {} + for param in custom_params: + param_name = ( + param.lower().replace(".", "_").replace("<", "").replace(">", "") + ) + if param == "Canvas.membership.permissions<>": + # This is a special parameter that requires a list of permissions to be passed in inside the angle brackets + # Canvas will return a filtered list of the permissions that the user has in the context. + if permissions: + param = f"Canvas.membership.permissions<{','.join(permissions)}>" + else: + # skip the Canvas.membership.permissions<> custom parameter if there are no permissions + continue + param_value = f"${param}" + custom_params_dict[param_name] = param_value + return custom_params_dict diff --git a/lti_dynamic_registration/views.py b/lti_dynamic_registration/views.py new file mode 100644 index 0000000..e69de29 diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..8c2e708 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,38 @@ +[build-system] +requires = ["setuptools>=61.0"] +build-backend = "setuptools.build_meta" + +[project] +name = "django-lti-dynamic-registration" +version = "0.1" +dependencies = [ + "django>=5.0", # Replace "X.Y" as appropriate + "django-lti>=0.5.0", +] +description = "A Django app to conduct web-based polls." +readme = "README.rst" +requires-python = ">= 3.10" +authors = [ + {name = "Your Name", email = "yourname@example.com"}, +] +classifiers = [ + "Environment :: Web Environment", + "Framework :: Django", + "Framework :: Django :: 5.0", + "Framework :: Django :: 5.1", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Operating System :: OS Independent", + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3 :: Only", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Topic :: Internet :: WWW/HTTP", + "Topic :: Internet :: WWW/HTTP :: Dynamic Content", +] + +[project.urls] +Homepage = "https://www.example.com/" \ No newline at end of file