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

Add local dev deployment for LTI 1.3 #130

Draft
wants to merge 11 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ repos:

# Autoformat: Python code
- repo: https://github.com/psf/black
rev: 22.12.0
rev: 23.1.0
hooks:
- id: black
# args are not passed, but see the config in pyproject.toml
Expand Down
14 changes: 14 additions & 0 deletions dev/Dockerfile.jupyterhub
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
ARG JUPYTERHUB_VERSION
FROM jupyterhub/jupyterhub:$JUPYTERHUB_VERSION

RUN python3 -m pip install --no-cache-dir \
dockerspawner==12.*

COPY . /ltiauthenticator

# Install ltiauthenticator
RUN python3 -m pip install --no-cache-dir \
/ltiauthenticator
# 'jupyterhub-ltiauthenticator @ git+https://github.com/martinclaus/ltiauthenticator@test-add-handler'

CMD ["jupyterhub", "-f", "/srv/jupyterhub/jupyterhub_config.py"]
81 changes: 81 additions & 0 deletions dev/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
# Development Deployment for LTI 1.3

This is a docker compose development deployment for Jupyterhub with ltiauthenticator using LTI 1.3.
It allows to test the [ltiauthenticator](https://github.com/jupyterhub/ltiauthenticator) package against the [LTI 1.3 DevKit](https://github.com/oat-sa/devkit-lti1p3) which is distributed separately.
This document provides information on how to set up and run both the dev deployment and the DevKit for local testing purposes.

## LTI 1.3 DevKit deployment

### Build and run

Clone the repository from Github to some location outside your project folder

```sh
git clone --depth 1 --branch 2.5.2 https://github.com/oat-sa/devkit-lti1p3.git
```

Switch to the newly created folder and build and run the docker stack

```sh
docker compose up -d
```

Then, install the required PHP dependencies

```
docker run --rm --interactive --tty \
--volume $PWD:/app \
composer install
```

After installation, the development kit is available on http://devkit-lti1p3.localhost

### Link to Hub deployment

To link the Jupyterhub and LTI platform deployments, add the LTI config to the DevKit deployment

```sh
cp <PATH-TO-LTIAUTHENTICATOR-PROJECT>/dev/devkit-lti1p3.yaml <PATH-TO-LTI-DEVKIT-DEPLOYMENT>/config/packages/lti1p3.yaml
```

## ltiauthenticator dev deployment

You need `docker` and `docker-compose` available on your local development machine.

### Build and run

Clone this repository to your local machine, if not already done.

```sh
git clone https://github.com/jupyterhub/ltiauthenticator.git
```

Switch to the `dev` subfolder and build the docker stack

```sh
cd dev
docker-compose build
```

This will pull a Jupyterhub image and installs your local development version of the `ltiauthenticator` package into it.
Then run the Jupyterhub:

```sh
docker compose up -d
```

This will make your hub available at http://dev-jh-lti.localhost:8000/.
To update the dev deployment with the most recent code changes, run both commands again.

Note that `devkit-lti1p3` must be running since the JupyterHub service will joint its network.

### Configure LTI13Authenticator

The relevant configuration for the LTI 1.3 authenticator within Jupyterhub can be set via environment variables in the [`docker-compose.yml`](./docker-compose.yml).
Those variables are prefixed by `LTI13`.

After applying changes to the configuration, refresh the compose project.

```sh
docker compose up -d
```
46 changes: 46 additions & 0 deletions dev/devkit-lti1p3.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
lti1p3:
scopes:
- 'https://purl.imsglobal.org/spec/lti-nrps/scope/contextmembership.readonly'
- 'https://purl.imsglobal.org/spec/lti-bo/scope/basicoutcome'
- 'https://purl.imsglobal.org/spec/lti-ap/scope/control.all'
- 'https://purl.imsglobal.org/spec/lti-ags/scope/lineitem'
- 'https://purl.imsglobal.org/spec/lti-ags/scope/lineitem.readonly'
- 'https://purl.imsglobal.org/spec/lti-ags/scope/score'
- 'https://purl.imsglobal.org/spec/lti-ags/scope/result.readonly'
key_chains:
platformKey:
key_set_name: "platformSet"
public_key: "file://%kernel.project_dir%/config/keys/public.key"
private_key: "file://%kernel.project_dir%/config/keys/private.key"
private_key_passphrase: ~
toolKey:
key_set_name: "toolSet"
public_key: "file://%kernel.project_dir%/config/keys/public.key"
private_key: "file://%kernel.project_dir%/config/keys/private.key"
private_key_passphrase: ~
platforms:
devkitPlatform:
name: "LTI 1.3 DevKit (as platform)"
audience: "%application_host%/platform"
oidc_authentication_url: "%application_host%/lti1p3/oidc/authentication"
oauth2_access_token_url: "%application_host%/lti1p3/auth/platformKey/token"
tools:
jupyterHub:
name: "JupyterHub LTI 1.3 Authenticator"
audience: "http://dev-jh-lti.localhost:8000/"
oidc_initiation_url: "http://dev-jh-lti.localhost:8000/hub/lti13/oauth_login"
launch_url: "http://dev-jh-lti.localhost:8000/hub/lti13/oauth_login"
deep_linking_url: "%application_host%/tool/launch"
registrations:
devkit:
client_id: "lti13authenticator-dev"
platform: "devkitPlatform"
tool: "jupyterHub"
deployment_ids:
- "deploymentId1"
- "deploymentId2"
platform_key_chain: "platformKey"
tool_key_chain: "toolKey"
platform_jwks_url: "%application_host%/lti1p3/.well-known/jwks/platformSet.json"
tool_jwks_url: "%application_host%/lti1p3/.well-known/jwks/toolSet.json"
order: 1
66 changes: 66 additions & 0 deletions dev/docker-compose.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
# Copyright (c) Jupyter Development Team.
# Distributed under the terms of the Modified BSD License.

# JupyterHub docker-compose configuration file
version: "3"

services:
hub:
build:
context: ../
dockerfile: dev/Dockerfile.jupyterhub
args:
JUPYTERHUB_VERSION: 3.1.0
restart: always
image: jupyterhub
container_name: jupyterhub
networks:
devkit_lti1p3_network:
aliases:
- dev-jh-lti.localhost
volumes:
# The JupyterHub configuration file
- "./jupyterhub_config.py:/srv/jupyterhub/jupyterhub_config.py:ro"
# Bind Docker socket on the host so we can connect to the daemon from
# within the container
- "/var/run/docker.sock:/var/run/docker.sock:rw"
# Bind Docker volume on host for JupyterHub database and cookie secrets
- "jupyterhub-data:/data"
ports:
- "8000:8000"
environment:
# User name claim containing a string which will be used by Jupyterhub as user name.
# Must be a unique identifyer of the user on the LTI Platform (LMS).
LTI13_USERNAME_KEY: sub
# The LTI 1.3 authorization url. The url of the platforms (LMS) endpoint for OAuth2
# authentication
LTI13_AUTHORIZE_URL: http://devkit-lti1p3.localhost/lti1p3/oidc/authentication
# The external tool's client id as represented within the platform (LMS)
# Note: the client id is not required by some LMS's for authentication.
# Only required, if the JupyterHub is suppose to send back information to the LMS
LTI13_CLIENT_ID: lti13authenticator-dev
# The JWKS endpoint of the platform (LMS).
LTI13_ENDPOINT: http://devkit-lti1p3.localhost/lti1p3/.well-known/jwks/platformSet.json
# The LTI 1.3 token url used to validate JWT signatures
LTI13_TOKEN_URL: http://devkit-lti1p3.localhost/lti1p3/auth/platformKey/token

# This username will be a JupyterHub admin
JUPYTERHUB_ADMIN: admin
# All containers will join this network
DOCKER_NETWORK_NAME: jupyterhub-network
# JupyterHub will spawn this Notebook image for users
DOCKER_NOTEBOOK_IMAGE: jupyter/minimal-notebook:latest
# Notebook directory inside user image
DOCKER_NOTEBOOK_DIR: /home/jovyan/work
# Using this run command
DOCKER_SPAWN_CMD: start-singleuser.sh
command: >
jupyterhub -f /srv/jupyterhub/jupyterhub_config.py

volumes:
jupyterhub-data:

networks:
devkit_lti1p3_network:
name: devkit-lti1p3_devkit_lti1p3_network
external: true
89 changes: 89 additions & 0 deletions dev/jupyterhub_config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
# Copyright (c) Jupyter Development Team.
# Distributed under the terms of the Modified BSD License.

# Configuration file for JupyterHub
import os

c = get_config()

c.Application.log_level = "DEBUG"

# We rely on environment variables to configure JupyterHub so that we
# avoid having to rebuild the JupyterHub container every time we change a
# configuration parameter.

##############################################################################
# LTI 1.3 Authenticator configuration
##############################################################################
# Authenticate users via LTI 1.3
c.JupyterHub.authenticator_class = "ltiauthenticator.lti13.auth.LTI13Authenticator"

# User name claim containing a string which will be used by Jupyterhub as user name.
# Must be a unique identifyer of the user on the LTI Platform (LMS).
c.LTI13Authenticator.username_key = os.environ["LTI13_USERNAME_KEY"]

# The LTI 1.3 authorization url. The url of the platforms (LMS) endpoint for OAuth2
# authentication
c.LTI13Authenticator.authorize_url = os.environ["LTI13_AUTHORIZE_URL"]

# The external tool's client id as represented within the platform (LMS)
# Note: the client id is not required by some LMS's for authentication.
# Only required, if the JupyterHub is suppose to send back information to the LMS
c.LTI13Authenticator.client_id = os.environ.get("LTI13_CLIENT_ID", "")

# The JWKS endpoint of the platform (LMS).
c.LTI13Authenticator.endpoint = os.environ["LTI13_ENDPOINT"]

# The LTI 1.3 token url used to validate JWT signatures
c.LTI13Authenticator.token_url = os.environ["LTI13_TOKEN_URL"]
##############################################################################


# Spawn single-user servers as Docker containers
c.JupyterHub.spawner_class = "dockerspawner.DockerSpawner"

# Spawn containers from this image
c.DockerSpawner.image = os.environ["DOCKER_NOTEBOOK_IMAGE"]

# JupyterHub requires a single-user instance of the Notebook server, so we
# default to using the `start-singleuser.sh` script included in the
# jupyter/docker-stacks *-notebook images as the Docker run command when
# spawning containers. Optionally, you can override the Docker run command
# using the DOCKER_SPAWN_CMD environment variable.
spawn_cmd = os.environ.get("DOCKER_SPAWN_CMD", "start-singleuser.sh")
c.DockerSpawner.cmd = spawn_cmd

# Connect containers to this Docker network
network_name = os.environ["DOCKER_NETWORK_NAME"]
c.DockerSpawner.use_internal_ip = True
c.DockerSpawner.network_name = network_name

# Explicitly set notebook directory because we'll be mounting a volume to it.
# Most jupyter/docker-stacks *-notebook images run the Notebook server as
# user `jovyan`, and set the notebook directory to `/home/jovyan/work`.
# We follow the same convention.
notebook_dir = os.environ.get("DOCKER_NOTEBOOK_DIR") or "/home/jovyan/work"
c.DockerSpawner.notebook_dir = notebook_dir

# Mount the real user's Docker volume on the host to the notebook user's
# notebook directory in the container
c.DockerSpawner.volumes = {"jupyterhub-user-{username}": notebook_dir}

# Remove containers once they are stopped
c.DockerSpawner.remove = True

# For debugging arguments passed to spawned containers
c.DockerSpawner.debug = True

# User containers will access hub by container name on the Docker network
c.JupyterHub.hub_ip = "jupyterhub"
c.JupyterHub.hub_port = 8080

# Persist hub data on volume mounted inside container
c.JupyterHub.cookie_secret_file = "/data/jupyterhub_cookie_secret"
c.JupyterHub.db_url = "sqlite:////data/jupyterhub.sqlite"

# Allowed admins
admin = os.environ.get("JUPYTERHUB_ADMIN")
if admin:
c.Authenticator.admin_users = [admin]
1 change: 0 additions & 1 deletion ltiauthenticator/lti11/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,6 @@ async def authenticate(self, handler: BaseHandler, data: dict = None) -> dict:
self.log.debug(f"Launch url is: {launch_url}")

if validator.validate_launch_request(launch_url, handler.request.headers, args):

# raise an http error if the username_key is not in the request's arguments.
if self.username_key not in args.keys():
self.log.warning(
Expand Down
8 changes: 7 additions & 1 deletion ltiauthenticator/lti13/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from jupyterhub.app import JupyterHub
from jupyterhub.auth import LocalAuthenticator
from jupyterhub.handlers import BaseHandler
from jupyterhub.utils import url_path_join
from oauthenticator.oauth2 import OAuthenticator
from tornado.web import HTTPError
from traitlets.config import List as TraitletsList
Expand Down Expand Up @@ -115,9 +116,14 @@ class LTI13Authenticator(OAuthenticator):
""",
)

def login_url(self, base_url):
return url_path_join(base_url, "lti13", "oauth_login")

def get_handlers(self, app: JupyterHub) -> List[BaseHandler]:
return [
("/lti13/config", LTI13ConfigHandler),
(r"/lti13/oauth_login", self.login_handler),
(r"/lti13/oauth_callback", self.callback_handler),
(r"/lti13/config", LTI13ConfigHandler),
]

async def authenticate(
Expand Down
12 changes: 7 additions & 5 deletions ltiauthenticator/lti13/handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,11 @@
import pem
from Crypto.PublicKey import RSA
from jupyterhub.handlers import BaseHandler
from jupyterhub.utils import url_path_join
from oauthenticator.oauth2 import (
OAuthCallbackHandler,
OAuthLoginHandler,
_serialize_state,
guess_callback_uri,
)
from tornado.httputil import url_concat
from tornado.web import HTTPError, RequestHandler
Expand Down Expand Up @@ -223,17 +223,19 @@ def post(self):

client_id = args["client_id"]
self.log.debug(f"client_id is {client_id}")
redirect_uri = guess_callback_uri(
"https", self.request.host, self.hub.server.base_url
)
self.log.info(f"redirect_uri: {redirect_uri}")

redirect_uri = self.authenticator.get_callback_url()
self.log.debug(f"redirect_uri is: {redirect_uri}")

state = self.get_state()
self.set_state_cookie(state)

# TODO: validate that received nonces haven't been received before
# and that they are within the time-based tolerance window
nonce_raw = hashlib.sha256(state.encode())
nonce = nonce_raw.hexdigest()
self.log.debug(f"nonce value: {nonce}")

self.authorize_redirect(
client_id=client_id,
login_hint=login_hint,
Expand Down
1 change: 0 additions & 1 deletion tests/lti11/test_lti11_authenticator.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,6 @@ async def test_authenticator_uses_lti11validator(
with patch.object(
LTI11LaunchValidator, "validate_launch_request", return_value=True
) as mock_validator:

authenticator = MockLTI11Authenticator()
authenticator.username_key = "custom_canvas_user_id"
handler = Mock(spec=RequestHandler)
Expand Down
Loading