Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
567bb7f
initial lti commit, doesn't work at all
20wildmanj Sep 30, 2022
f66bd08
get ims-lti import to start working, got authentication through
20wildmanj Oct 1, 2022
1399751
try using faraday
20wildmanj Oct 7, 2022
b5d39ae
Play around with Canvas API / NRPS
20wildmanj Oct 17, 2022
8f06a97
Begin implementing LTI Advantage launch
20wildmanj Oct 29, 2022
bc4fd86
work on validation of final launch
20wildmanj Oct 31, 2022
f85e4c5
Continue validation, display final resource
20wildmanj Nov 2, 2022
a6f8967
clean up files, routes, gems
20wildmanj Nov 2, 2022
8742b20
add yml template
20wildmanj Nov 2, 2022
2b2532e
add comments to yml template
20wildmanj Nov 2, 2022
1fd1db1
- run rubocop
20wildmanj Nov 2, 2022
e2d9fa8
more comments
20wildmanj Nov 2, 2022
5a5a09d
- add space
20wildmanj Nov 2, 2022
e778cc8
only bundle update for jwt
20wildmanj Nov 3, 2022
ecb06af
undo rake db migrate
20wildmanj Nov 3, 2022
8c570e1
redo db creation (still has lots of modifications)
20wildmanj Nov 3, 2022
c514026
untrack schema
20wildmanj Nov 3, 2022
5096656
retry schema.rb fix
20wildmanj Nov 3, 2022
fe298fc
Merge branch 'master' into joeywildman-init-lti-setup
20wildmanj Nov 3, 2022
fcd205e
ignore lti_settings.yml
20wildmanj Nov 3, 2022
e6820df
Remove hardcoding of hostname / url
20wildmanj Nov 8, 2022
33d6c0b
change prefix setup code
20wildmanj Nov 8, 2022
97932e2
remove unnecessary id param
20wildmanj Nov 8, 2022
e1d06f9
- address nits
20wildmanj Nov 13, 2022
05d7534
Merge branch 'master' into joeywildman-init-lti-setup
20wildmanj Nov 13, 2022
cf8ca6c
Add expiry for nonce
20wildmanj Nov 13, 2022
80db34d
Merge branch 'joeywildman-init-lti-setup' of github.com:autolab/Autol…
20wildmanj Nov 13, 2022
633444d
Merge branch 'master' into joeywildman-init-lti-setup
20wildmanj Nov 13, 2022
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ coverage/
/gradebooks/
config/database.yml
config/school.yml
config/lti_settings.yml

# autolab user documents
app/views/home/_topannounce.html.erb
Expand Down
2 changes: 2 additions & 0 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -161,3 +161,5 @@ gem 'mimemagic', '>= 0.3.7'
# For encrypting API tokens
gem 'lockbox'

# to decode / verify jwts for LTI Integration
gem "jwt"
5 changes: 3 additions & 2 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -172,7 +172,7 @@ GEM
json (2.6.2)
jstz-rails3-plus (1.0.5)
railties (>= 3.1)
jwt (2.4.1)
jwt (2.5.0)
libv8-node (16.10.0.0)
libv8-node (16.10.0.0-arm64-darwin)
libv8-node (16.10.0.0-x86_64-darwin)
Expand Down Expand Up @@ -440,6 +440,7 @@ DEPENDENCIES
jquery-rails
js_cookie_rails
jstz-rails3-plus (>= 1.0)
jwt
lockbox
materialize-sass (= 1.0.0)
mimemagic (>= 0.3.7)
Expand Down Expand Up @@ -483,4 +484,4 @@ RUBY VERSION
ruby 2.6.8p205

BUNDLED WITH
2.3.9
2.3.19
3 changes: 3 additions & 0 deletions app/assets/stylesheets/lti.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
// Place all the styles related to the l controller here.
// They will automatically be included in application.css.
// You can use Sass (SCSS) here: https://sass-lang.com/
223 changes: 223 additions & 0 deletions app/controllers/lti_launch_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,223 @@
class LtiLaunchController < ApplicationController
respond_to :json
skip_before_action :set_course
skip_before_action :authorize_user_for_course
skip_before_action :update_persistent_announcements
skip_before_action :authenticate_for_action

# have to do because we are making a POST request from Canvas
skip_before_action :verify_authenticity_token
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed

Check failure

Code scanning / CodeQL

CSRF protection weakened or disabled

Potential CSRF vulnerability due to forgery protection being disabled or weakened.

action_auth_level :launch, :instructor
class LtiError < StandardError
def initialize(msg, status_code = :bad_request)
@status_code = status_code
super(msg)
end
end
rescue_from LtiError, with: :respond_with_lti_error

def respond_with_lti_error(error)
Rails.logger.debug(error)
Rails.logger.send(:warn) { "Lti Error: #{error.message}" }
render json: { error: error.message }.to_json, status: :bad_request
end

# validate we get iss login_hint params for oidc entrypoint
def validate_oidc_login(params)
# Validate Issuer. Different than other LTI implementations since for now
# we will only support integration with one service, if more than one
# integration enabled, then changed to check a list of issuers
if params['iss'].nil? && params['iss'] != Rails.configuration.lti_settings["iss"]
raise LtiError.new("Could not find issuer", :bad_request);
end

# Validate Login Hint.
return unless params['login_hint'].nil?

raise LtiError.new("Could not find login hint", :bad_request);
end

# check state matches what was already sent in oidc_login
def validate_state(params)
if params["state"].nil?
raise LtiError.new("no state found", :bad_request)
end
# match previous state cookie from oidc_login
return unless cookies["lti1p3_#{params['state']}"] != params["state"]

raise LtiError.new("state cookie not found or correct", :bad_request)
end

# ensure id_token is a valid jwt
def validate_jwt_format(id_token)
if id_token.nil?
raise LtiError.new("no id token found in request", :bad_request)
end

jwt_parts = id_token.split(".")
if jwt_parts.size != 3
raise LtiError.new("JWT not valid", :bad_request)
end

@jwt = { header: JSON.parse(Base64.urlsafe_decode64(jwt_parts[0])),
body: JSON.parse(Base64.urlsafe_decode64(jwt_parts[1])),
sig: JSON.parse(Base64.urlsafe_decode64(jwt_parts[1])) }
end

# validate nonce is same as initially sent during oidc_login
def validate_nonce
if @jwt[:body]["nonce"].nil?
raise LtiError.new("no nonce found in request", :bad_request)
end

cache_nonce = Rails.cache.read("nonce-#{@user.id}")
if cache_nonce.nil?
raise LtiError.new("nonce in cache expired", :bad_request)
end
return unless cache_nonce != @jwt[:body]["nonce"]

raise LtiError.new("nonce doesn't match cache", :bad_request)
end

# validate issuer, client_id should be same as stored in our settings
def validate_registration
client_id = @jwt[:body]['aud'].is_a?(Array) ? @jwt[:body]['aud'][0] : @jwt[:body]['aud'];
if client_id != Rails.configuration.lti_settings["developer_key"]
# Client not registered.
raise LtiError.new("client id not registered for issuer", :bad_request)
end
return unless @jwt[:body]['iss'] != Rails.configuration.lti_settings["iss"]

raise LtiError.new("iss doesn't match config", :bad_request)
end

# Right now, we only allow / validate LtiResourceLinkRequest
# since this is the message type needed for launches to get
# course context information needed for syncing
def validate_link_request
message_type = @jwt[:body]["https://purl.imsglobal.org/spec/lti/claim/message_type"]
if message_type.nil? || message_type != "LtiResourceLinkRequest"
raise LtiError.new("LTI launch is not an LtiResourceLinkRequest", :bad_request)
end

id = @jwt[:body]["https://purl.imsglobal.org/spec/lti/claim/resource_link"]["id"]
if id.nil?
raise LtiError.new("Missing Resource Link ID", :bad_request)
end
# checking for required fields of id token
# http://www.imsglobal.org/spec/security/v1p0/#id-token
if @jwt[:body]['sub'].nil?
raise LtiError.new("sub required in LTI launch", :bad_request)
end
# check that claim version is for LTI Advantage
if @jwt[:body]['https://purl.imsglobal.org/spec/lti/claim/version'] != "1.3.0"
raise LtiError.new("launch claim version is not 1.3.0", :bad_request)
end
return unless @jwt[:body]["https://purl.imsglobal.org/spec/lti/claim/roles"].nil?

raise LtiError.new("Roles claim not found", :bad_request)
end

# make sure that we are given the context_memberships_url
# otherwise, we can't call / access NRPS
def validate_nrps_access
# rubocop:disable Layout/LineLength
if @jwt[:body]['https://purl.imsglobal.org/spec/lti-nrps/claim/namesroleservice'].nil? ||
@jwt[:body]['https://purl.imsglobal.org/spec/lti-nrps/claim/namesroleservice']['context_memberships_url'].nil? ||
@jwt[:body]['https://purl.imsglobal.org/spec/lti-nrps/claim/namesroleservice']['context_memberships_url'].empty?
raise LtiError.new("NRPS context membership url not found", :bad_request)
end
# rubocop:enable Layout/LineLength
end

def validate_jwt_signature(id_token)
rsa_public = OpenSSL::PKey::RSA.new(Rails.configuration.lti_settings["platform_public_key"])
begin
JWT.decode id_token, rsa_public, true, { algorithm: 'RS256' }
rescue JWT::ExpiredSignature
# Handle expired token, e.g. logout user or deny access
raise LtiError.new("JWT signature expired", :bad_request)
end
rescue JWT::ImmatureSignature
# Handle invalid token, e.g. logout user or deny access
raise LtiError.new("JWT signature invalid", :bad_request)
end

# final LTI launch flow endpoint
# validate id_token, jwt, check we have NRPS access
# redirect to users/:id/lti_launch_initialize for final linking
def launch
# Code based on:
# https://github.com/IMSGlobal/lti-1-3-php-library/blob/master/src/lti/LTI_Message_Launch.php
@user = current_user
validate_state(params)
id_token = params["id_token"]
validate_jwt_format(id_token)
validate_jwt_signature(id_token)
validate_nonce
validate_registration
validate_link_request
validate_nrps_access
if !current_user.present?
raise LtiError.new("Not logged in!", :bad_request)
end

redirect_to controller: "users", action: "lti_launch_initialize",
launch_context: @jwt[:body], id: @user.id
end

# LTI launch entrypoint to initiate open id connect login
# build our authentication response and redirect back to
# platform
def oidc_login
# code based on: https://github.com/IMSGlobal/lti-1-3-php-library/blob/master/src/lti/LTI_OIDC_Login.php
# validate OIDC
validate_oidc_login(params)
# Build OIDC Auth Response
# Generate State.
# Set cookie (short lived)
state = SecureRandom.uuid
stateCookie = "lti1p3_#{state}"
cookies[stateCookie] = { value: state, expires_in: 1.hour }

# generate nonce, store in cache for user
@user = current_user
nonce = "nonce-#{SecureRandom.uuid}"
Rails.cache.write("nonce-#{@user.id}", nonce, expires_in: 3600)
prefix = "https://"
if ENV["DOCKER_SSL"] == "false"
prefix = "http://"
end
begin
hostname = if Rails.env.development?
request.base_url
else
prefix + request.host
end
rescue StandardError
hostname = `hostname`
hostname = prefix + hostname.strip
end

# build response
auth_params = {
"scope": "openid", # oidc scope
"response_type": "id_token", # oidc response is always an id token
"response_mode": "form_post", # oidc response is always a form post
"client_id": Rails.configuration.lti_settings["developer_key"], # client id (developer key)
"redirect_uri": "#{hostname}/lti_launch/launch", # URL to return to after login
"state": state, # state to identify browser session
"nonce": nonce, # nonce to prevent replay attacks
"login_hint": params["login_hint"] # login hint to identify platform session
}
unless params["lti_message_hint"].nil?
auth_params["lti_message_hint"] = params["lti_message_hint"]
end

# put auth params as URL query parameters for redirect
@encoded_params = URI.encode_www_form(auth_params)

redirect_to "#{Rails.configuration.lti_settings['auth_url']}?#{@encoded_params}"
end
end
14 changes: 13 additions & 1 deletion app/controllers/users_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ class UsersController < ApplicationController
redirect_to("/home/error_404")
end
before_action :set_gh_oauth_client, only: [:github_oauth, :github_oauth_callback]
before_action :set_user, only: [:github_oauth, :github_revoke]
before_action :set_user, only: [:github_oauth, :github_revoke, :lti_launch_initialize]

# GET /users
action_auth_level :index, :student
Expand Down Expand Up @@ -197,6 +197,18 @@ def destroy
redirect_to(users_path) && return
end

def lti_launch_initialize
@launch_context = params[:launch_context]
# get courses where user is instructor
@cuds = if current_user.administrator?
# if current user is admin, show whatever he requests
@user.course_user_data
else
# look for cud in courses where current user is instructor of
@user.course_user_data.filter(&:instructor?)

end
end
action_auth_level :github_oauth, :student
def github_oauth
github_integration = GithubIntegration.find_by(user_id: @user.id)
Expand Down
27 changes: 27 additions & 0 deletions app/views/users/lti_launch_initialize.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
<h4> LTI Linking </h4>
Comment thread
20wildmanj marked this conversation as resolved.
<h4><%= @user.display_name %></h4>
<ul class="gray-box">
<li>
<b> Information from LTI resource </b>
<ul> Launch email: <%= @launch_context["email"] %></ul>
<ul> Course context id: <%= @launch_context["https://purl.imsglobal.org/spec/lti/claim/context"]["id"] %></ul>
<ul> Course title: <%= @launch_context["https://purl.imsglobal.org/spec/lti/claim/context"]["title"] %></ul>
<ul> Scopes: <%= @launch_context["scope"] %></ul>
<ul> Course memberships url: <%= @launch_context["https://purl.imsglobal.org/spec/lti-nrps/claim/namesroleservice"]["context_memberships_url"] %></ul>
</li>
<li>
<b>
Linkable courses
</b>
<% if @cuds.empty? %>
<strong> None </strong>
<% else %>
<ul>
<% @cuds.each do |cud| %>
<li><%= link_to cud.course.display_name, edit_course_course_user_datum_path(cud.course, cud) %>
</li>
<% end %>
</ul>
<% end %>
</li>
</ul>
2 changes: 2 additions & 0 deletions config/application.rb
Original file line number Diff line number Diff line change
Expand Up @@ -116,5 +116,7 @@ class Application < Rails::Application

# site version
config.site_version = "2.9.0"

config.lti_settings = Rails.application.config_for(:lti_settings)
end
end
12 changes: 12 additions & 0 deletions config/lti_settings.yml.template
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
# settings necessary to integrate with an LTI platform
development:
# find issuer identifier from the LTI platform's documentation
iss: <issuer>
# also known as client_id, also obtained from the lTI platform
developer_key: <client_id>
# authentication url of the LTI platform for LTI launches
auth_url: <lti_platform_auth_url>
#public key of the platform. A dummy public key format is provided below
platform_public_key: "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA1+ACVFVSZ6ee2Iv2A3S4O+fcrz4ki3Sigh+1vQcCCbi1arb5najJby9r0rrHbgqpTVM7/LLGvQz8siLUBKdvRAQo6tC0zsI+sbNn4Nu1kK339gk83w56DdqDNsPJohb2Zmkc3GOZYVTncIA9AGrdA2l0R64nWK2gl8y948bRIj9r0fzWgwlIAVwHXmlMoLNBZ6MdcaX85pia/gLBKp4ZFZNF8zWms8h2k9f08AbB2bME04jTfjGOIsHrPYWDfzGyHAueKV9000nzRgQ0LS+ponwXjXZ1Ocqu86sxws19g75TBrpoz+GRODXkwRTJBenlKI+Va7oyUEQZ6jYmgmdFwQIDAQAB\n-----END PUBLIC KEY-----\n"


5 changes: 5 additions & 0 deletions config/routes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@
# For details on the DSL available within this file, see https://guides.rubyonrails.org/routing.html

use_doorkeeper
post 'lti_launch/oidc_login', to: "lti_launch#oidc_login"
get 'lti_launch/oidc_login', to: "lti_launch#oidc_login"
post 'lti_launch/launch', to: "lti_launch#launch"
get 'lti_launch/launch', to: "lti_launch#launch"

namespace :oauth, { defaults: { format: :json } } do
get "device_flow_init", to: "device_flow#init"
Expand Down Expand Up @@ -65,6 +69,7 @@
resources :users do
get "admin"
get "github_oauth", on: :member
get "lti_launch_initialize", on: :member
post "github_revoke", on: :member
get "github_oauth_callback", on: :collection
end
Expand Down