An Elixir library for LTI 1.3 Platforms and Tools.
This library implements the Learning Tools Interoperability (LTI) 1.3 Specification for Tool and Platform integrations in Elixir. You can use this library to develop an LTI 1.3 Tool or Platform (or both). The data persistence layer is "pluggable" and can be configured according to the Data Providers section below.
The package can be installed by adding lti_1p3
to your list of dependencies in mix.exs
:
def deps do
[
{:lti_1p3, "~> 0.4.5"}
]
end
Documentation can be found at https://hexdocs.pm/lti_1p3.
Add the following to config/config.exs
:
use Mix.Config
# ... existing config
config :lti_1p3,
provider: Lti_1p3.DataProviders.MemoryProvider
# ... import_config
The provider configured here is the default in-memory persistence provider which means any registrations or deployments created will be lost when your app is stopped or restarted. To persist data across restarts you will need to specify a durable provider such as the EctoProvider or implement a custom data provider using the DataProvider behavior. Refer to the Data Providers section below for more details.
Whether you are planning on implementing a tool or a platform, you must create and expose a public Jwk regardless. This Jwk should be available from an endpoint to be used by the other party for verification of tokens which were signed using the private key counterpart. Note that both tool and platform will have their own separate and distinct Jwks, however if your app happens to be both a tool and platform you can simply reuse the same Jwk for both. NEVER expose or share the private key which is generated by this function. The security of the LTI handshake depends on it's secrecy.
# Create an active Jwk. Typically this is done once at startup, in a database seed script
# or when keys are rotated by the tool. This will be reused across registration creations
%{private_key: private_key} = Lti_1p3.KeyGenerator.generate_key_pair()
{:ok, jwk} = Lti_1p3.create_jwk(%Lti_1p3.Jwk{
pem: private_key,
typ: "JWT",
alg: "RS256",
kid: "some-unique-kid",
active: true,
})
Create an endpoint to expose all public Jwks. For example, using a Phoenix controller:
defmodule MyAppWeb.LtiController do
use MyAppWeb, :controller
...
def jwks(conn, _params) do
keys = Lti_1p3.get_all_public_keys()
conn
|> json(keys)
end
...
end
If you are using Phoenix, don't forget to add the endpoint to your router.ex
:
get "/.well-known/jwks.json", LtiController, :jwks
These keys can be considered site-wide metadata and as such can reside in the .well-known
path per RFC 5785.
If you are unfamiliar with LTI 1.3, please refer to the the LTI 1.3 Launch Overview.
Before a launch can be performed, a platform must be registered with your tool by creating a Jwk, Registration and Deployment. A Registration represents the details provided by a platform administrator. For the simplest case, if your tool only needs to integrate with a single platform this can be hard coded in at startup or in a simple database seed script. For the more common case, if your tool needs to support multiple runtime-configurable platform integrations, this registration process will most likely be implemented in something more akin to a web form, such as using a Phoenix controller.
# this jwk is the same jwk we generated in the section above
{:ok, jwk} = Lti_1p3.get_active_jwk()
# Create a Registration, Details are typically provided by the platform administrator for this registration.
{:ok, registration} = Lti_1p3.Tool.create_registration(%Lti_1p3.Tool.Registration{
issuer: "https://platform.example.edu",
client_id: "1000000000001",
key_set_url: "https://platform.example.edu/.well-known/jwks.json",
auth_token_url: "https://platform.example.edu/access_tokens",
auth_login_url: "https://platform.example.edu/authorize_redirect",
auth_server: "https://platform.example.edu/oauth2/aud/token",
tool_jwk_id: jwk.id,
})
# Create a Deployment. Essentially this a unique identifier for a specific registration launch point,
# for which there can be many for a single registration. This will also typically be provided by a
# platform administrator.
{:ok, _deployment} = Lti_1p3.Tool.create_deployment(%Lti_1p3.Tool.Deployment{
deployment_id: "some-deployment-id",
registration_id: registration.id,
})
Your tool implementation will need to have 2 tool-specific endpoints for handling LTI requests. The first will be a login
endpoint, which will issue a login request back to the platform. The second will be a launch
endpoint, which will validate the lti launch details and if successful, display the resource. The details of both of these steps is outlined in the LTI 1.3 Launch Overview. You will need to provide both of these endpoint urls to the platform as part of their registration process for your tool.
The first endpoint, login
, uses the Lti_1p3.Tool.OidcLogin
module to validate the request and return a state key and redirect_uri. For example:
defmodule MyAppWeb.LtiController do
use MyAppWeb, :controller
def login(conn, params) do
case Lti_1p3.OidcLogin.oidc_login_redirect_url(params) do
{:ok, state, redirect_url} ->
conn
|> put_session("state", state)
|> redirect(external: redirect_url)
{:error, %{reason: :invalid_registration, msg: _msg, issuer: issuer, client_id: client_id}} ->
handle_invalid_registration(conn, issuer, client_id)
{:error, %{reason: _reason, msg: msg}} ->
render(conn, "lti_error.html", msg: msg)
end
end
...
end
Notice how the returned state is stored in the session so that it can be used later in the launch request. The user is then redirected to the returned redirect_url. In the case where an error is returned, a map with the reason code, error message, and any additional data associated with the specific error is returned and can be handled accordingly.
The second endpoint, launch
, uses the Lti_1p3.Tool.LaunchValidation
module to validate the launch and extract the lti claims. For example:
defmodule MyAppWeb.LtiController do
use MyAppWeb, :controller
...
def launch(conn, params) do
session_state = Plug.Conn.get_session(conn, "state")
case Lti_1p3.Tool.LaunchValidation.validate(params, session_state) do
{:ok, claims} ->
handle_valid_lti_1p3_launch(conn, claims)
{:error, %{reason: :invalid_registration, msg: _msg, issuer: issuer, client_id: client_id}} ->
handle_invalid_registration(conn, issuer, client_id)
{:error, %{reason: :invalid_deployment, msg: _msg, registration_id: registration_id, deployment_id: deployment_id}} ->
handle_invalid_deployment(conn, registration_id, deployment_id)
{:error, %{reason: _reason, msg: msg}} ->
render(conn, "lti_error.html", reason: msg)
end
end
...
end
If successful, validate
returns the LTI claims from the request.
If you are using Phoenix, don't forget to add these endpoints to your router.ex
. The LTI 1.3 specification says the login
request can be sent as either a GET
or POST
, so we must support both methods.
post "/login", LtiController, :login
get "/login", LtiController, :login
post "/launch", LtiController, :launch
Additional Note: As modern browsers continue to limit the ability of iFrames to set cookies from within a page from another domain (which is typically how an LTI resource is displayed on a platform by default) it becomes more unreliable to use cookie-based session storage for things like
state
andlti_1p3_sub
key. If you run into issues related to session data not being stored consistently across requests, please verify that the cookie is actually being set in the browser and also try initiating the launch into a new tab instead of in an iframe.
If you are unfamiliar with LTI 1.3, please refer to the the LTI 1.3 Launch Overview.
Before your platform can initiate a launch request, you must first create a Platform Instance with the details provided by the tool publisher or developer. A platform instance represents an integration with a specific tool and can be created using the Lti_1p3.Platform
module. Typically a platform will provide some sort of web form to create these for every tool the platform will launch into.
{:ok, platform_instance} = Lti_1p3.Platform.create_platform_instance(%PlatformInstance{
name: "Some Example Tool",
target_link_uri: "https://tool.example.edu/launch",
client_id: "1000000000001",
login_url: "https://tool.example.edu/login",
keyset_url: "https://tool.example.edu/.well-known/jwks.json",
redirect_uris: "https://tool.example.edu/launch",
})
The choice of client_id here is somewhat arbitrary and can simply be an incrementing integer or guid-based. The only constraint is that it must be unique. This client_id will be provided to the tool as part of it's configuration details.
Your platform implementation will need to have an authorize_redirect
endpoint for handling platform-specific LTI requests which will verify the current user logged in is the same user who initiated the request using the login_hint and then use the Lti_1p3.AuthorizationRedirect
module to authorize the LTI details by verifying the LTI details provided by the tool and if successful, render a form that will post the final LTI request and params to the tool. For example:
defmodule MyAppWeb.LtiController do
use MyAppWeb, :controller
...
def authorize_redirect(conn, params) do
issuer = "https://platform.example.edu"
deployment_id = "some-deployment-id"
# current user can be any map or struct that has an id: %{id: user_id}
current_user = conn.assigns[:current_user]
case Lti_1p3.AuthorizationRedirect.authorize_redirect(params, current_user, issuer, deployment_id) do
{:ok, redirect_uri, state, id_token} ->
conn
|> render("post_redirect.html", redirect_uri: redirect_uri, state: state, id_token: id_token)
{:error, %{reason: _reason, msg: msg}} ->
render(conn, "lti_error.html", reason: msg)
end
end
end
Example of post_redirect.html
, a self-submitting POST form with state and id_token containing the final LTI params:
<!DOCTYPE html>
<html lang="en">
<head>
<title>You are being redirected...</title>
</head>
<body>
<div>You are being redirected...</div>
<form name="post_redirect" action="<%= @redirect_uri %>" method="post">
<input type="hidden" name="state" value="<%= @state %>" />
<input type="hidden" name="id_token" value="<%= @id_token %>" />
<noscript>
<input type="submit" value="Click here to continue" />
</noscript>
</form>
<script type="text/javascript">
window.onload = function () {
document.getElementsByName("post_redirect")[0].style.display = "none";
document.forms["post_redirect"].submit();
};
</script>
</body>
</html>
Data providers are implementations of the DataProvider
behavior which provide data persistance for the library. In most cases, the non-durable MemoryProvider or persistent EctoProvider will be sufficient.
Name | Module | Description |
---|---|---|
Memory Provider (Default) | Lti_1p3.DataProviders.MemoryProvider |
An Elixir agent-based, non-durable in-memory store |
Ecto Provider | Lti_1p3.DataProviders.EctoProvider |
An Ecto-based, persistent store (External Dependency: https://github.com/Simon-Initiative/lti_1p3_ecto_provider) |
To use a specific data provider, simply install the provider dependency and set the module you would like to use as your provider
in config.config.ex
.
use Mix.Config
# ... existing config
config :lti_1p3,
provider: Lti_1p3.DataProviders.EctoProvider
# ... import_config
Depending on your persistence setup, you may want to implement your own custom data provider using the DataProvider
behavior which can also be set in config/config.ex
.
use Mix.Config
# ... existing config
config :lti_1p3,
provider: MyApp.DataProviders.CustomProvider
# ... import_config
This library was built for the purposes of supporting the Open Learning Initiative's next generation learning platform, Torus. For a complete implementation of all the concepts discussed here and usage of this library, take a look at the open source repository on Github, specifically lti_controller.ex.