Handling LTI Launches in Rails
This article explains how to set up your Rails application as an LTI Tool Provider, to handle LTI 1.3 launches from an LMS, including the OCID workflow, user authentication and what to do with the information you get from the LMS.
If you’re reading this, you probably already know what LTI is. If not, here’s the Wikipedia article.
For these examples, I’m using Canvas LMS, so the implementation details will be specific to Canvas. Because LTI is a standard, most of the details should be the same across platforms, but the specifics around configuring the LMS and some of the terminology may differ. I’ve only tested the approach with Canvas.
The first part of this article will discuss handling an LTI launch and authenticating the user who initiated the launch. A future article will cover how to do a LtiDeepLinkingResponse, which allows us to return rich content to be embedded in the LMS.
Let’s travel through the exciting world of LTI, OIDC, JWTs, JWKs and more.
Overview
In this guide, Tool Provider refers to the application that we’re building.
LTI 1.3 uses OpenID Connect (OIDC) third-party flow. I won’t go into great detail, but at a very high level:
-
The LMS initiates a POST request to the Tool Provider, to the URL that we provide when we configure the tool in the LMS.
-
The Tool Provider redirects the browser back to the LMS, to an “authorization endpoint” provided by the LMS. This request requires a crafted URL parameter derived from the original request.
-
The LMS redirects back to the Tool Provider, to a different URL specified in the LMS tool config. This request contains an “ID token” and a signed JWT containing a LTI payload.
-
Finally, the Tool Provider can validate the response, authenticate the user and direct the browser to the appropriate resource.
Let’s dig in.
Step 1: Login Initiation
Your application will need to respond to a number of requests specific to the OIDC launch process, the first of which is the Login Initiation. Let’s create a controller to handle the OIDC launch, and our first route. All you RESTful routes purists, avert your eyes.
1
post "/oidc/initiation", to: "oidc#initiation"
1
2
3
4
class OIDCController < ApplicationController
def initiation
end
end
The URL for this route is what we will supply in our Developer Key “OpenID Connect Initiation URL” field.
Step 2: Redirect to Authentication Endpoint
We now need to craft the redirect back to the LMS. We use a number of parameters from the original request as well as the redirect URL supplied by the LMS out-of-band. We will also use the openid_connect gem to do a bit of the magic for us. Let’s create a PORO model to handle the logic of building the URL.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
class OIDCAuthorizationUri
def initialize(state:, nonce:, login_hint:, lti_message_hint:, tool_id:, redirect_host:)
@state = state
@nonce = nonce
@login_hint = login_hint
@lti_message_hint = lti_message_hint
@tool_id = tool_id
@redirect_host = redirect_host
end
def to_s
client.authorization_uri(
state: @state,
nonce: @nonce,
login_hint: @login_hint,
lti_message_hint: @lti_message_hint,
prompt: "none",
response_mode: "form_post"
)
end
private
def client
@client ||= OpenIDConnect::Client.new(
identifier: @tool_id,
redirect_uri: "https://#{@redirect_host}/oidc/callback",
host: config[:oidc_auth_host],
authorization_endpoint: config[:oidc_auth_path]
)
end
def config
# This part is up to you. These values are supplied by the LMS
# For Canvas in production the values are
# host: "sso.canvas.instructure.com"
# path: "/api/lti/authorize_redirect"
{
oidc_auth_host: "",
oidc_auth_path: ""
}
end
end
Let’s also add some inflections for all of the initialisms we’re working with.
1
2
3
4
ActiveSupport::Inflector.inflections(:en) do |inflect|
inflect.acronym "LTI"
inflect.acronym "OIDC"
end
In the OIDC controller, we can create a new OIDCAuthorizationURL
and redirect to it. We create a few session variables to help validate the response in the next step.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
def initiation
set_session_params
redirect_to auth_uri.to_s, allow_other_host: true
end
private
def set_session_params
session[:state] = SecureRandom.hex(16)
session[:nonce] = SecureRandom.hex(16)
end
def auth_uri
@auth_uri ||= OIDCAuthorizationUri.new(
state: session[:state],
nonce: session[:nonce],
login_hint: params[:login_hint],
lti_message_hint: params[:lti_message_hint],
tool_id: params[:client_id],
issuer: params[:iss],
redirect_host: request.hostname
)
end
Step 3: Authentication Response
The LMS now bounces back to a different URL on our Tool Provider, specified in the “Redirect URIs” section of the Developer Key config.
It sends along a JWT with a lot of useful information. I wrote an article exploring the contents of the JWT. We will deal with that in the next step.
Let’s add the route and controller action to handle the authentication response.
1
post "/oidc/callback", to: "oidc#callback"
1
2
3
4
5
class OIDCController < ApplicationController
# ...
def callback
end
end
The response contains a signed JWT in the id_token
param. This JWT is chock full of details about the LTI Launch, the launch context (like the course in which the tool was embedded) the user and LMS platform itself.
Step 4: Validate & Authenticate
We have a few hoops to jump through in this step:
-
Decode the JWT response from the LMS
-
Validate the JWT
-
Pull some useful information from the JWT which we can use to authenticate the user
-
Redirect to our content
Next, we decode the JWT. For that we will need the public JWKs provided by the LMS. We will also leverage the gem json-jwt to do the heavy lifting. Let’s build a model to encapsulate that behaviour. This will handle fetching the public JWKs that are used to validate the token, and decoding the value into something useful.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
class JWTContent
def initialize(id_token_string)
@id_token_string = id_token_string
end
def id_token
@id_token ||= JSON::JWT.decode(@id_token_string, jwk_set)
end
private
def jwk_set
@jwk_set ||= JSON::JWK::Set.new(
jwk_uris
.filter_map { |jwk_uri| fetch_jwk(jwk_uri) }
.reduce(:|)
)
end
def fetch_jwk(uri)
JSON::JWK::Set::Fetcher.fetch(
uri,
kid: nil,
auto_detect: false
)
end
def config
{
jwk_uris: [] # Provide these
}
end
end
Verify the Token
We also need to perform some verification on the token. The full verification requirements are explained in the LTI Security Framework. We add a #verify
method to the JWTContent class.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class JWTContent
def verify(lms_platform_id:, tool_client_id:, nonce:)
azp_valid = id_token[:azp] ? (id_token[:azp] == tool_client_id) : true
unless azp_valid &&
id_token[:sub].present? &&
id_token[:iss] == lms_platform_id &&
id_token[:aud] == tool_client_id &&
id_token[:nonce] == nonce &&
OauthNonce.validate(nonce, tool_client_id) &&
Time.at(id_token[:iat]).between?(30.seconds.ago, Time.now) &&
Time.at(id_token[:exp]) > Time.now
raise "ID Token Verification Failed!"
end
end
end
We store and check nonces to prevent replay attacks; here’s a simple model to save them in the database. If your application has a cache store, use it instead.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class OauthNonce < ApplicationRecord
validates :nonce, :consumer_key, presence: true
def self.validate(nonce, consumer_key)
record = find_by(nonce: nonce, consumer_key: consumer_key)
if record.present?
# If the nonce already exists, it is invalid
false
else
# Otherwise, create a new record with the nonce and consumer key
create(nonce: nonce, consumer_key: consumer_key, created_at: Time.now)
purge_old_records
true
end
end
def self.purge_old_records
where("created_at < ?", 5.minutes.ago).delete_all
end
end
1
2
3
4
5
6
7
8
9
10
11
12
class CreateOauthNonces < ActiveRecord::Migration[7.0]
def change
create_table :oauth_nonces do |t|
t.string :nonce, null: false
t.string :consumer_key, null: false
t.datetime :created_at, null: false
end
add_index :oauth_nonces, [:nonce, :consumer_key], unique: true
add_index :oauth_nonces, :created_at
end
end
Dealing With the Token Contents
Calling JWTContent.new(jwt).id_token
will return an hash-like containing fields such as:
1
2
3
4
5
6
7
8
9
{
"https://purl.imsglobal.org/spec/lti/claim/version"=>"1.3.0",
"azp"=>"163950000000000106",
"exp"=>1714746467,
"iat"=>1714742867,
"nonce"=>"0c369dfd1d51c28dc4dd47d3ba164823",
"https://purl.imsglobal.org/spec/lti/claim/custom"=>{...}
...
}
We can wrap this content another model to encapsulate the access details.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
module LTI
class LaunchContext
def self.build(payload)
new(
message_type: claim_value("message_type", payload),
lti_version: claim_value("lti_version", payload),
deployment_id: claim_value("deployment_id", payload),
target_link_uri: claim_value("target_link_uri", payload),
custom: claim_value("custom", payload),
return_url: claim_value("launch_presentation", payload)["return_url"],
deep_link_return_url: payload["https://purl.imsglobal.org/spec/lti-dl/claim/deep_linking_settings"]["deep_link_return_url"],
aud: payload["aud"],
azp: payload["azp"],
iss: payload["iss"],
sub: payload["sub"]
)
end
def self.claim_value(claim, payload)
payload["https://purl.imsglobal.org/spec/lti/claim/#{claim}"]
end
def initialize(*params)
# ... set your instance variables
end
def user_sis_id
custom["user_sis_id"]
end
def course_sis_id
custom["course_sis_id"]
end
end
end
(This example assumes custom variables configured on the Developer Key)
Developer Key Custom Fields
1
2
user_sis_id=$Canvas.user.sisSourceId
course_sis_id=$Canvas.course.sisSourceId
There’s a lot of info in the JWT. You can extract it all into the LaunchContext object, or only extract the specific fields you need. In this case, I’m extracting details needed for a DeepLinking response. (I’m also turning this model into an ActiveRecord, more on that in a future article)
In our controller, we can use the LaunchContext to get the info we actually want. We create a JWTContent instance to decode the JWT passed from the LMS, and pass that into the LTI::LaunchContext.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
class OIDCController < ApplicationController
def callback
end
private
def jwt_content
@jwt_content ||= begin
content = JWTContent.new(params[:id_token])
content.verify(
lms_platform_id: config[:lms_platform_id],
tool_client_id: config[:tool_client_id],
nonce: session[:nonce]
)
content.id_token
end
end
def lti_launch_context
@lti_launch_context ||= LTI::LaunchContext.build(jwt_content)
end
def cleanup_session_params
session.delete :state
session.delete :nonce
end
def config
# Provide these details
{
lms_platform_id: "", #https://canvas.instructure.com
tool_client_id: "" # This value is specific to your account and the tool being launched
}
end
end
Now all that’s left is to authenticate the user and redirect.
1
2
3
4
5
6
7
8
class OIDCController < ApplicationController
def callback
user_id = lti_launch_context.user_sis_id
# maybe log in the user
cleanup_session_params
redirect_to lti_launch_context.target_link_uri
end
end
Conclusion
There you have it, a complete LTI 1.3 Launch. Stay tuned for a future articles, where we delve into more the DeepLinking launch and response.