Skip to content

Conversation

@dsubak
Copy link
Contributor

@dsubak dsubak commented Oct 23, 2025

Description (What does it do?)

This is a proof of concept for setting up Learn as an LTI platform for use with Jupyterhub as a tool provider. The primary feature we're interested in using is the ability to pass a user's role and some supporting course metadata from Learn to Jupyterhub, which LTI supports via custom claims in their tool launch request.

This was tested against a local checkout of Jupyterhub's Dockerspawner simple example, using the following configuration:

from typing import Dict, Any

from dockerspawner.dockerspawner import DockerSpawner

c = get_config()  # noqa

# we need the hub to listen on all ips when it is in a container
c.JupyterHub.hub_ip = '0.0.0.0'
# the hostname/ip that should be used to connect to the hub
# this is usually the hub container's name
c.JupyterHub.hub_connect_ip = 'jupyterhub'

# pick a docker image. This should have the same version of jupyterhub
# in it as our Hub.
c.DockerSpawner.image = 'jupyter/base-notebook'

# tell the user containers to connect to our docker network
c.DockerSpawner.network_name = 'jupyterhub'

# delete containers when the stop
c.DockerSpawner.remove = True

c.DockerSpawner.allowed_images = '*'
c.DockerSpawner.start_timeout = 600

class TestLTISpawner(DockerSpawner):

    def start(self):
        image = self.environment.get('IMAGE')
        if image:
            self.image = image

        return super().start()


c.JupyterHub.spawner_class = TestLTISpawner  # type: ignore[name-defined] # noqa: F821

from ltiauthenticator.lti13.auth import LTI13Authenticator
from ltiauthenticator.lti13.handlers import LTI13LoginInitHandler
ADMINISTRATOR = 'http://purl.imsglobal.org/vocab/lis/v2/system/person#Administrator'
INSTRUCTOR = 'http://purl.imsglobal.org/vocab/lis/v2/institution/person#Instructor'
CUSTOM_CLAIM_KEY = 'https://purl.imsglobal.org/spec/lti/claim/custom'
class LearnLTIAuthenticator(LTI13Authenticator):


    async def authenticate(
            self, handler: LTI13LoginInitHandler, data: Dict[str, str] = None
    ) -> Dict[str, Any]:
        """
        Handles LTI 1.3 launch requests based on a passed JWT.

        Args:
          handler: handler object
          data: authentication dictionary. The decoded, verified and validated id_token send by the platform

        Returns:
          Authentication dictionary
        """
        if not data:
            data = {}

        roles = data.get('https://purl.imsglobal.org/spec/lti/claim/roles')
        if INSTRUCTOR in roles or ADMINISTRATOR in roles:
            print('User is staff')
        else:
            print('User is a learner')


        username = self.get_username(data)

        return {
            "name": username,
            "auth_state": data,
        }


# dummy for testing. Don't use this in production!
c.JupyterHub.authenticator_class = LearnLTIAuthenticator
c.LearnLTIAuthenticator.issuer = 'http://api.open.odl.local'
c.LearnLTIAuthenticator.client_id = ['learn-jupyter-notebooks']
c.LearnLTIAuthenticator.authorize_url = 'http://api.open.odl.local:8065/lti_auth'
# This is only really necessary because we're running this in docker and hitting a local service fronted by /etc/hosts
# In practice, we'll use services exposed via public DNS entries
c.LearnLTIAuthenticator.jwks_endpoint = 'http://host.docker.internal:8065/lti_jwks'
c.Authenticator.enable_auth_state = True
c.Authenticator.allow_all = True
}

How can this be tested?

This assumes you can access Learn Django APIs at http://api.open.odl.local:8065/ and that you are using Docker Desktop (as we use host.docker.internal for routing one portion of the negotiation)

Set up Jupyterhub

  • Clone the repo containing https://github.com/jupyterhub/dockerspawner, navigate to examples/simple.
  • Add jupyterhub-ltiauthenticator to requirements.txt
  • Replace the contents of jupyterhub_config.py with the configuration above.
  • Run the following command:
    docker network create jupyterhub && docker build -t hub . && docker run --rm -it -v /var/run/docker.sock:/var/run/docker.sock --net jupyterhub --name jupyterhub -p8000:8000 hub
  • If everything ran successfully, you should be able to visit localhost:8000 and see a Jupyterhub "Sign in with LTI 1.3" page
Screenshot 2025-10-24 at 11 49 27 AM

Set up Learn

  • In the root of your mit-learn checkout, add a private and public key. You can use https://lti-ri.imsglobal.org/keygen/index for generation if you'd like. Name them platform_id_rsa and platform_id_rsa.pub respectively
  • Start your server via docker-compose up

Run the actual test

  • Log into Learn as a user.
  • Visit http://api.open.odl.local:8065/lti_login. This will render a form asking for a course and image. Choose any of the notebook images from https://jupyter-docker-stacks.readthedocs.io/en/latest/using/selecting.html, such as quay.io/jupyter/pytorch-notebook. The course can be anything you like.
  • Submit the form. This should eventually redirect you to a Jupyterhub spawn screen, and once the notebook launches it should have all the packages installed for the image selected (for example, if you specified pytorch-notebook, you should be able to import torch successfully)
  • If you examine the Jupyterhub logs there should be a message User is staff or User is a learner depending on whether or not you've logged in as a superuser to Learn.

Additional Context

This PR uses the openedx xblock-lti-consumer package - specifically it leverages the use-agnostic 1.3 consumer. Unfortunately this means that we pull in a lot of extra unused functionality, but it is sufficiently flexible that we can plug it in anywhere. The only other packaged platform implementation for LTI 1.3 that I found was https://pypi.org/project/lti1p3platform/ but since I couldn't find a corresponding repository for it, I opted for this one.

This proof of concept exists to demonstrate one possibility for identifying users vs authors within our jupyter notebooks. For more information in how this may be used, see this writeup for a discussion on the potential approaches.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants