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(lti13): Reintroduce lti13 support #134

Merged
merged 33 commits into from
Mar 23, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
d213d98
Revert "Remove LTI13Authenticator related logic"
martinclaus Mar 1, 2023
bc6dd91
Revert "Remove LTI13 from docs"
martinclaus Mar 1, 2023
f25f8c6
Revert "Drop unused dependencies"
martinclaus Mar 1, 2023
1d8374e
Clarify naming of constants
martinclaus Mar 1, 2023
c55199f
Remove obsolete JWS code
martinclaus Mar 2, 2023
6a119fa
Remove JWKS endpoint from published config
martinclaus Mar 2, 2023
1bc825a
Streamline login init request handling
martinclaus Mar 2, 2023
4bb4d2d
Validate auth response arguments
martinclaus Mar 2, 2023
4797bfe
Move auth response validation into handler
martinclaus Mar 2, 2023
1ab509e
Test handler invocations
martinclaus Mar 3, 2023
f125da9
Fix Python 3.8 typing issue
martinclaus Mar 3, 2023
93f4052
Extend testing of handler
martinclaus Mar 3, 2023
97776ab
Test for missing user
martinclaus Mar 3, 2023
5566b89
Validate platform issuer identifyer
martinclaus Mar 3, 2023
27ffe3a
Test aud validation
martinclaus Mar 3, 2023
a6f2e0b
Refactor and cleaning
martinclaus Mar 6, 2023
77f49a4
Use custom exceptions
martinclaus Mar 6, 2023
8870a94
Reject unsecured JWT if JWKS endpoint is specified
martinclaus Mar 6, 2023
32359cb
Refactor validation
martinclaus Mar 6, 2023
2247ba8
Validate azp claim
martinclaus Mar 6, 2023
f4c42a0
Disallow GET for launch request
martinclaus Mar 6, 2023
1941555
Fix exception testing
martinclaus Mar 6, 2023
a13b3e7
Improve help of configurable objects
martinclaus Mar 6, 2023
c885962
Fix required id_token claims
martinclaus Mar 6, 2023
dfa917f
Do not rely on external endpoint
martinclaus Mar 6, 2023
4de5f01
Add tests for iat and exp validation
martinclaus Mar 7, 2023
6550575
Add leeway support for time validation
martinclaus Mar 7, 2023
451d7a8
Reject too old id_token
martinclaus Mar 7, 2023
85c1901
Prevent replay attacks
martinclaus Mar 7, 2023
f75ae0f
Code cleaning
martinclaus Mar 7, 2023
68d7a5a
Bump dependencies
martinclaus Mar 8, 2023
e49d450
Drop default values for mandatory configuration
martinclaus Mar 8, 2023
54c7068
Fix docstring
martinclaus Mar 13, 2023
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
120 changes: 120 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -264,3 +264,123 @@ The Moodle setup is very similar to both the Open edX and Canvas setups describe
```

1. [401 Unauthorized] - [Canvas] Make sure you added your JupyterHub link by first specifying the tool via the 'Find' button (Step 4.2). Otherwise your link will not be sending the appropriate key and secret and your launch request will be recognized as unauthorized.

### LTI 1.3

#### Common Configuration Settings

Like LTI 1.1, LTI 1.3 is an open standard. Many Learning Management System (LMS) vendors support the LTI 1.3 standard and as such vendors are able to integrate with various LMS's as External Tools.

Start by following the steps below to configure your JupyterHub setup with the basic settings. Then, navigate to your LMS vendor's section to complete the installation and configuration steps.

> **Note**: if your LMS is not listed feel free to send us a PR with instructions for this new LMS.

The table below describes the configuration options available with the LTI v1.1 authenticator:

| LTI Authenticator Configuration Setting | Required | Description | Default |
| --------------------------------------- | -------- | ------------------------------------------------------------------------ | ---------------------------------- |
| config_description | No | The LTI 1.1 external tool description | `JupyterHub LTI 1.1 external tool` |
| config_icon | No | The http/s URL with the LTI 1.1 icon | `nil` |
| config_title | No | The LTI 1.1 external tool Title | `JupyterHub` |
| consumers | Yes | The key/value pair that represents the client key and shared secret | `{}` |
| username_key | No | The LTI 1.1 launch parameter that contains the JupyterHub username value | `canvas_custom_user_id` |

#### The Username Key Setting (LTI11Authenticator.username_key)

Regardless of the LMS vendor you are using (Canvas, Moodle, Open edX, etc), the user's name will default to use the `custom_canvas_user_id`. (This is due to legacy behavior and will default to a more generic LTI 1.1 parameter in a future release). Change the `username_key` setting if you would like to use another value from the LTI 1.1 launch request.

The example below illustrates how to fetch the user's email to set the JupyterHub username by specifying the `lis_person_contact_email_primary` LTI 1.1 launch request parameter:

```python
# Set the user's email as their user id
c.LTIAuthenticator.username_key = 'lis_person_contact_email_primary'
```

A [partial list of keys in an LTI request](https://www.edu-apps.org/code.html#params) is available as a reference if you would like to use another value to set the JupyterHub username. As a general rule of thumb, Personally Identifiable Information (PII) values are represented with the `lis_person_*` arguments in the launch request. Your LMS provider might also implement custom keys you can use, such as with the use of [custom parameter substitution](https://www.imsglobal.org/specs/ltiv1p1p1/implementation-guide).

#### LTI 1.3 Configuration JSON Settings

The LTI 1.3 configuration XML settings are available at `/lti11/config` endpoint. Some LMS vendors accept XML and/or URLs that render the configuration XML to simplify the LTI 1.1 External Tool installation process.

You may customize these settings with the `config_*` configuration options described in the [common configuration settings](#common-configuration-settings) section.

#### Custom Configuration with JupyterHub's Helm Chart

If you are running **JupyterHub within a Kubernetes Cluster**, deployed using helm, you need to supply the LTI 1.3 (OIDC/OAuth2) endpoints. The example below also demonstrates how customize the `lti13.username_key` to set the user's give name:

```yaml
# Custom config for JupyterHub's helm chart
hub:
config:
# Additional documentation related to authentication and authorization available at
# https://zero-to-jupyterhub.readthedocs.io/en/latest/administrator/authentication.html
JupyterHub:
authenticator_class: ltiauthenticator.lti13.auth.LTI13Authenticator
LTI13Authenticator:
# Use an LTI 1.3 claim to set the username. You can use and LTI 1.3 claim that
# identifies the user, such as email, last_name, etc.
username_key: "given_name"
# The issuer identifyer of the platform
issuer: "https://canvas.instructure.com"
# The LTI 1.3 authorization url
authorize_url: "https://canvas.instructure.com/api/lti/authorize_redirect"
# 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.
client_id: "125900000000000329"
# The LTI 1.3 endpoint url, also known as the OAuth2 callback url
endpoint: "http://localhost:8000/hub/oauth_callback"
# The LTI 1.3 token url used to validate JWT signatures
token_url: "https://canvas.instructure.com/login/oauth2/token"
```

#### Configuration of LTI 1.3 with the Learning Management System

#### Canvas

The setup for the Canvas LMS.

##### Configure the JupyterHub as as a Developer Key

1. [To install the JupyterHub as an External Tool](https://community.canvaslms.com/t5/Canvas-Releases-Board/Canvas-Release-LTI-1-3-and-LTI-Advantage-2019-06-22/td-p/246652) admin users need to create a `Developer Key`. (More detailed instructions and screen shots on how to access this section are provided in the link above).

1. Once the Developer Key configuration for is open then select the `Enter URL` method within the `Configure` -> `Method` dropdown. This allows admin users to add the JupyterHub configuration by referring to a JupyterHub endpoint that renders the LTI 1.3 Developer Key configuration in JSON. By default the configuration URL is structured with the `https://<my-hub.domain.com>/hub/lti13/config` format.

1. Add the `Redirect URIs`. By default, the redirect URI is equivalent to the callback URL. As such the default URL for the Redirect URIs field should be `https://<my-hub.domain.com>/hub/oauth_callback`.

1. Add a `Key Name` to identify the `Developer Key`.

1. (Optional) Enter owner's email and developer key notes.

1. Save the `Developer Key` settings by clicking on the `Save` button.

##### Enable the Developer Key and copy Client ID

1. You should now see the new `Developer Key` in the `Admin` -> `Developer Keys` -> `Accounts` tab. By default the Developer Key is disabled. Enable the JupyterHub installation by clicking on the On/Off toggler in the `State` column to `ON`.

1. Copy the value that represents the `Client ID` in the `Datails` column. This value should look something like `125900000000000318`.

##### Install the JupyterHub as an External Tool in your Canvas Course

1. Navigate to the course where you would like to enable the JupyterHub.

1. Click on the course's `Settings` link.

1. Click on the `Apps` tab and then on `View App Configurations`.

1. Click on the `+App` button to add a new application. Select `By Client ID` from the `Configuration Type` dropdown and paste the `Client ID` value that you copied from the `Developer Keys` -> `<Your Developer Key Name>` -> `Details` column.

1. Save the application.

Once the application is saved you will see the option to launch the JupyterHub from the Course navigation menu. You will also have the option to add `Assignments` as an `External Tool`.

**Privacy Settings**:

Like the Privacy Settings for LTI 1.1, the LTI 1.3 External Tool application in [Canvas may be configured with privacy enabled](https://community.canvaslms.com/t5/Canvas-Developers-Group/LTI-1-3-Configuration-Claims-Course-Placement-Privacy/td-p/229252). The user ID in these cases will fetch the value from the LTI 1.3 subject (`sub` claim) which is a unique and opaque identifier for the student.

##### Create a new assignment as an External Tool

To configure an assignment with LTI 1.3 as an External Tool [follow the instructions from the LTI 1.1 -> Create a new assignment section](#create-a-new-assignment-as-an-external-tool).

#### Common Gotchas

Refer to the [common gotchas](#common-gotchas) section in the LTI 1.1 section.
22 changes: 22 additions & 0 deletions examples/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,28 @@ jupyterhub --config /path/to/jupyterhub_config_lti11.py

If the actual username value is different from the expected username value, then view the JupyterHub logs in `debug` (the provided examples have `c.Application.log_level = "DEBUG"`) mode to confirm that the original launch request keys/values are correct.

### LTI 1.3

#### Validate Platform Settings

Ensure the `authorization_url`, `client_id`, `token_url`, `endpoint`, and `callback_url` have the correct values. The provided example has standard settings for the Canvas LMS.

#### Test the `username_key` settings with LTI 1.3 launches

This example demonstrates how users can change the `username_key` to fetch values from the LTI 1.3 login initiation flows that can be used to set the username.

1. Confirm the `username_key` value in the provided `jupyterhub_config_lti13.py` example:

Edit the provided `jupyterhub_config_lti13.py` to change the `username_key` to another value to represent the user's username. You can basically use any LTI 1.3 claims that represent Personably Identifiable Information (PII).

You could also use a parameter substitution to extract PII values from your LMS. To obtain a full list of possible parameter substitution settings refer to the IMS Global LTI 1.3 [implementation guide](https://www.imsglobal.org/spec/lti/v1p3/) -> Apendix B: Custom parameter substitution.

1. Start the JupyterHub with the example configuration for LTI 1.3:

```bash
jupyterhub --config /path/to/jupyterhub_config_lti13.py
```

### Trouble Shooting

#### The username_key does not fetch the correct value from the launch requests
Expand Down
43 changes: 43 additions & 0 deletions examples/jupyterhub_config_lti13.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
""" Example JupyterHub configuration file with LTI 1.3 settings. """
import os

# Set port and IP
c.JupyterHub.ip = "0.0.0.0"
c.JupyterHub.port = 8000

# Custom base url
c.JupyterHub.base_url = "/"

# Set log level
c.Application.log_level = "DEBUG"

# Add an admin user for testing the admin page
c.Authenticator.admin_users = {"admin"}

# Enable auth state to pass the authentication dictionary
# auth_state to ths spawner
c.Authenticator.enable_auth_state = True

# Set the LTI 1.1 authenticator.
c.JupyterHub.authenticator_class = "ltiauthenticator.lti13.auth.LTI13Authenticator"

# Use an LTI 1.3 claim to set the username.
c.LTI13Authenticator.username_key = "given_name"

# Define issuer identifier of the LMS platform
c.LTI13Authenticator.issuer = (
os.getenv("LTI13_ISSUER") or "https://canvas.instructure.com"
)
# Add the LTI 1.3 configuration options
c.LTI13Authenticator.authorize_url = (
os.getenv("LTI13_AUTHORIZE_URL")
or "https://canvas.instructure.com/api/lti/authorize_redirect"
)
c.LTI13Authenticator.client_id = os.getenv("LTI13_OAUTH_CLIENT_ID") or ""
c.LTI13Authenticator.jwks_endpoint = (
os.getenv("LTI13_JWKS_ENDPOINT")
or "https://canvas.instructure.com/api/lti/security/jwks"
)
# Validator setting
c.LTI13LaunchValidator.time_leeway = int(os.getenv("LTI13_TIME_LEEWAY", "0"))
c.LTI13LaunchValidator.max_age = int(os.getenv("LTI13_MAX_AGE", "600"))
Empty file.
168 changes: 168 additions & 0 deletions ltiauthenticator/lti13/auth.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
import logging
from typing import Any, Dict, List

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 import List as TraitletsList
from traitlets import Unicode

from .handlers import LTI13CallbackHandler, LTI13ConfigHandler, LTI13LoginInitHandler

logger = logging.getLogger(__name__)
logger.setLevel(logging.DEBUG)


class LTI13Authenticator(OAuthenticator):
"""
JupyterHub LTI 1.3 Authenticator which extends the `OAuthenticator` class. (LTI 1.3
is basically an extension of OIDC/OAuth2). Messages sent to this authenticator are sent
from a LTI 1.3 Platform, such as an LMS. JupyterHub, as the authenticator, works as the
LTI 1.3 External Tool. The basic login flow is authentication using the implicit flow. As such,
the client id is always required.

This class utilizes the following required configurables defined in the `OAuthenticator` base class:

- authorize_url
- client_id

Ref:
- https://github.com/jupyterhub/oauthenticator/blob/master/oauthenticator/oauth2.py
- http://www.imsglobal.org/spec/lti/v1p3/
- https://openid.net/specs/openid-connect-core-1_0.html#ImplicitFlowAuth
"""

login_service = "LTI 1.3"

# handlers used for login, callback, and jwks endpoints
login_handler = LTI13LoginInitHandler
callback_handler = LTI13CallbackHandler

jwks_algorithms = TraitletsList(
default_value=["RS256"],
config=True,
help="""
Supported algorithms for signing JWT. The actual algorithm is declared by the authenticator
in the `id_token_signed_response_alg` parameter during out-of-band registration.

References:
https://www.imsglobal.org/spec/security/v1p0/#authentication-response-validation
""",
)

issuer = Unicode(
allow_none=False,
config=True,
help="""
The platform's issuer identifier. It is a case-sensitive URL, using the HTTPS
scheme, that contains scheme, host, and optionally, port number, and path components,
and no query or fragment components. It is provided by the platform.
""",
)

jwks_endpoint = Unicode(
allow_none=False,
config=True,
help="""
The platform's JWKS endpoint used to obtain it's JSON Web Key Set to validate JWT signatures.
""",
)

username_key = Unicode(
"email",
allow_none=False,
config=True,
help="""
JWT claim present in LTI 1.3 login initiation flow used to set the user's JupyterHub's username.
Some common examples include:

- User's email address: email
- Given name: given_name

Your LMS (Canvas / Open EdX / Moodle / others) may provide additional keys in the
LTI 1.3 login initiatino flow that you can use to set the username. In most cases these
are located in the `https://purl.imsglobal.org/spec/lti/claim/custom` claim. You may also
have the option of using variable substitutions to fetch values that aren't provided with
your vendor's standard LTI 1.3 login initiation flow request. If your platform's LTI 1.3
settings are defined with privacy enabled, then by default the `sub` claim is used to set the
username.

Reference the IMS LTI specification on variable substitutions:
http://www.imsglobal.org/spec/lti/v1p3/#customproperty.
""",
)

tool_name = Unicode(
"JupyterHub",
config=True,
help="""
Name of tool provided to the LMS when installed via the config URL.

This is primarily used for display purposes.
""",
)

tool_description = Unicode(
"Launch interactive Jupyter Notebooks with JupyterHub",
config=True,
help="""
Description of tool provided to the LMS when installed via the config URL.

This is primarily used for display purposes.
""",
)

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

def callback_url(self, base_url):
return url_path_join(base_url, "lti13", "oauth_callback")

def config_json_url(self, base_url):
return url_path_join(base_url, "lti13", "config")

def get_handlers(self, app: JupyterHub) -> List[BaseHandler]:
return [
(self.login_url(""), self.login_handler),
(self.callback_url(""), self.callback_handler),
(self.config_json_url(""), LTI13ConfigHandler),
]

async def authenticate(
self, handler: LTI13LoginInitHandler, data: Dict[str, str] = None
) -> Dict[str, Any]:
"""
Overrides authenticate from the OAuthenticator base class to handle 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 tehe platform

Returns:
Authentication dictionary
"""
if data is None:
data = {}
username = data.get(self.username_key)
if not username:
username = data.get("sub")
if not username:
raise HTTPError(
400,
f"Unable to set the username with username_key {self.username_key}",
)

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


class LocalLTI13Authenticator(LocalAuthenticator, OAuthenticator):
"""A version that mixes in local system user creation"""

pass
Loading