-
Notifications
You must be signed in to change notification settings - Fork 235
Implement Initial LTI 1.3 Launch #1635
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
Merged
Merged
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 f66bd08
get ims-lti import to start working, got authentication through
20wildmanj 1399751
try using faraday
20wildmanj b5d39ae
Play around with Canvas API / NRPS
20wildmanj 8f06a97
Begin implementing LTI Advantage launch
20wildmanj bc4fd86
work on validation of final launch
20wildmanj f85e4c5
Continue validation, display final resource
20wildmanj a6f8967
clean up files, routes, gems
20wildmanj 8742b20
add yml template
20wildmanj 2b2532e
add comments to yml template
20wildmanj 1fd1db1
- run rubocop
20wildmanj e2d9fa8
more comments
20wildmanj 5a5a09d
- add space
20wildmanj e778cc8
only bundle update for jwt
20wildmanj ecb06af
undo rake db migrate
20wildmanj 8c570e1
redo db creation (still has lots of modifications)
20wildmanj c514026
untrack schema
20wildmanj 5096656
retry schema.rb fix
20wildmanj fe298fc
Merge branch 'master' into joeywildman-init-lti-setup
20wildmanj fcd205e
ignore lti_settings.yml
20wildmanj e6820df
Remove hardcoding of hostname / url
20wildmanj 33d6c0b
change prefix setup code
20wildmanj 97932e2
remove unnecessary id param
20wildmanj e1d06f9
- address nits
20wildmanj 05d7534
Merge branch 'master' into joeywildman-init-lti-setup
20wildmanj cf8ca6c
Add expiry for nonce
20wildmanj 80db34d
Merge branch 'joeywildman-init-lti-setup' of github.com:autolab/Autol…
20wildmanj 633444d
Merge branch 'master' into joeywildman-init-lti-setup
20wildmanj File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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/ |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 | ||
Check failureCode 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 | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,27 @@ | ||
| <h4> LTI Linking </h4> | ||
|
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> | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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" | ||
|
|
||
|
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.