Lti 1p3

Hex.pm GitHub Build & Test Coverage Status

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.

Installation

The package can be installed by adding lti_1p3 to your list of dependencies in mix.exs:

def deps do
  [
    {:lti_1p3, "~> 0.1.0"}
  ]
end

Documentation can be found at https://hexdocs.pm/lti_1p3.

Getting Started

Config

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.

Jwk

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.

LTI 1.3 Tool Example

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",
  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, cache the LTI params from the request and 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 cache the lti params. 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, lti_params, key} ->
        # store key in the session so that the cached lti_params can be retrieved in later requests
        conn = conn
          |> Plug.Conn.put_session(:lti_1p3_key, key)

        handle_valid_lti_1p3_launch(conn, lti_params)

      {: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 params from the request as well as a key for the cached lti params, which can be used to retrieve the the LTI params associated with the user's latest launch by using the Lti_1p3.Tool module. This key is guaranteed to be unique to the platform, user, and context_id and reproducible given the same values. This means when a different set of LTI params for the same platform, user, and context_id are received, the generated key will be identical and the corresponding cached params will be updated to the new values.

Note, this example of storing the lti_1p3_key assumes only a single platform will use the tool. If you expect more that one platform to use your tool concurrently, you may want to build out a more rich structure of storing these lti param keys in the session, such as including the full universal scope consisting of a platform identifier, user identifier and context identifier all in the session to guarantee the correct lti params can be loaded for a particular platform, user, and context.

%Lti_1p3.Tool.LtiParams{params: lti_params} = Lti_1p3.Tool.get_lti_params_by_sub(sub)

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 and lti_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.

LTI 1.3 Platform Example

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

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.

Existing Data Providers

NameModuleDescription
Memory Provider (Default)Lti_1p3.DataProviders.MemoryProviderAn Elixir agent-based, non-durable in-memory store
Ecto ProviderLti_1p3.DataProviders.EctoProviderAn 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

Custom Data Provider

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

Full LTI 1.3 Implementation Example

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.