Properly lookup 1.3->1.1 mapping during 1.3 launch
This reverts commit 0e703b84c0
.
why:
- Previously, work was done to add the oauth_consumer_key and
oauth_consumer_key_sign to LTI 1.3 launches that are associated with a
1.1 tool.
- This work is important, as it improves the migration experience for
tools.
- However, the previous attempt at this did not account for tools within
production Canvas having invalid URLs/domains. While we do validate
URLs now, we didn't always, so there are some bad tools still hanging
out in the wild.
- Additionally, while the previous commit did add a feature flag, it
didn't add it properly, meaning that even though the flag was off, it
was still looking up the association, it just wasn't adding the
property to the 1.3 Message. This commit does it properly now.
fixes INTEROP-8124
flag=include_oauth_consumer_key_in_lti_launch
test-plan:
- The first half of this test plan is taken verbatim from the previous
commit:
------ Start Old Test Plan
- Clone Xander's handy dandy Remix 1.1/1.3 test tool and run it locally.
https://github.com/xandroxygen/lti_1p1_test_tool
- Run the tool and then install it locally by following the directions
for both 1.1 and 1.3.
- Launch the 1.3 tool and make sure that under the 1.1 claims section,
there is an oauth_consumer_key with value key and an
oauth_consumer_key_sign section. You don't have to check the
signature, as the algorithm for it is unit tested using values from
IMS's examples from the spec itself.
- Now delete the 1.1 tool and launch the 1.3 tool again. You should
still see the oauth_consumer_key info.
------ End Old Test Plan
- Now, to test that things won't broke when there are malformed tools,
do the following:
- Create a new course.
- Manually create a new tool in the rails console in this course with
malformed data. We have to bypass some rails validations to mock prod.
```ruby
tool = Course.last.context_external_tools.new(url: "http://url path>};/invalidurl}",
domain: "url path>};"
settings: {
"text"=>"LTI 1.1 Tool with Garbage Data",
"visibility"=>"public",
"custom_fields"=>{"user_id"=>"$Canvas.user.id"},
"course_navigation"=>
{"enabled"=>true,
"url"=>"http://url path>};/invalidurl}/launch?placement=course_navigation",
"text"=>"LTI 1.1 Garbage Data",
"selection_width"=>500,
"selection_height"=>500,
"message_type"=>"basic_lti_request",
"visibility"=>"none"},
"vendor_extensions"=>[]
})
tool.save(validate: false)
```
- Launch the LTI 1.3 test tool from this new course. You should NOT see
an oauth_consumer_key & oauth_consumer_key_sign in the LTI 1.1 claims.
More importantly, the launch should succeed without a 500.
- Delete the manually created tool and launch the 1.3 tool again. All
should be the same as above.
Change-Id: Icea1ed5fd0a316fac51ba87591ea79b2002d5a9d
Reviewed-on: https://gerrit.instructure.com/c/canvas-lms/+/322172
Reviewed-by: Xander Moffatt <xmoffatt@instructure.com>
QA-Review: Xander Moffatt <xmoffatt@instructure.com>
Product-Review: Ryan Hawkins <ryan.hawkins@instructure.com>
Tested-by: Service Cloud Jenkins <svc.cloudjenkins@instructure.com>
This commit is contained in:
parent
4485ba0c0d
commit
8d74397b9b
|
@ -704,6 +704,7 @@ class ContextExternalTool < ActiveRecord::Base
|
|||
# launch_url overrides are only considered when include_launch_url: true is
|
||||
# provided, and are preferred over domain overrides. Query strings from the
|
||||
# base_url and launch_url override will be merged together.
|
||||
# @param base_url [String]
|
||||
def url_with_environment_overrides(base_url, include_launch_url: false)
|
||||
return base_url unless use_environment_overrides?
|
||||
|
||||
|
@ -854,13 +855,17 @@ class ContextExternalTool < ActiveRecord::Base
|
|||
end
|
||||
|
||||
def self.standardize_url(url)
|
||||
return "" if url.blank?
|
||||
return nil if url.blank?
|
||||
|
||||
url = url.gsub(/[[:space:]]/, "")
|
||||
url = "http://" + url unless url.include?("://")
|
||||
res = Addressable::URI.parse(url).normalize
|
||||
res.query = res.query.split("&").sort.join("&") unless res.query.blank?
|
||||
res.to_s
|
||||
begin
|
||||
res = Addressable::URI.parse(url)&.normalize
|
||||
res.query = res.query.split("&").sort.join("&") if res&.query.present?
|
||||
res
|
||||
rescue Addressable::URI::InvalidURIError
|
||||
nil
|
||||
end
|
||||
end
|
||||
|
||||
alias_method :destroy_permanently!, :destroy
|
||||
|
@ -882,10 +887,10 @@ class ContextExternalTool < ActiveRecord::Base
|
|||
end
|
||||
|
||||
def standard_url(use_environment_overrides = false)
|
||||
standard_url = url.present? && ContextExternalTool.standardize_url(url)
|
||||
standard_url = ContextExternalTool.standardize_url(url)
|
||||
|
||||
if use_environment_overrides
|
||||
ContextExternalTool.standardize_url(url_with_environment_overrides(standard_url, include_launch_url: true))
|
||||
ContextExternalTool.standardize_url(url_with_environment_overrides(standard_url.to_s, include_launch_url: true))
|
||||
else
|
||||
standard_url
|
||||
end
|
||||
|
@ -903,27 +908,28 @@ class ContextExternalTool < ActiveRecord::Base
|
|||
# risking breaking Canvas flows, we introduced this
|
||||
# new method.
|
||||
def matches_host?(url, use_environment_overrides: false)
|
||||
standard_url = standard_url(use_environment_overrides)
|
||||
matches_tool_domain?(url) ||
|
||||
(standard_url(use_environment_overrides).present? &&
|
||||
Addressable::URI.parse(standard_url(use_environment_overrides))&.normalize&.host ==
|
||||
Addressable::URI.parse(url).normalize.host)
|
||||
(standard_url.present? &&
|
||||
standard_url.host == ContextExternalTool.standardize_url(url)&.host)
|
||||
end
|
||||
|
||||
def matches_url?(url, match_queries_exactly = true, use_environment_overrides: false)
|
||||
tool_url = standard_url(use_environment_overrides)
|
||||
if match_queries_exactly
|
||||
url = ContextExternalTool.standardize_url(url)
|
||||
return true if url == tool_url
|
||||
url == tool_url
|
||||
elsif tool_url.present?
|
||||
unless defined?(@url_params)
|
||||
res = Addressable::URI.parse(tool_url)
|
||||
@url_params = res.query.present? ? res.query.split("&") : []
|
||||
@url_params ||= tool_url.query&.split("&") || []
|
||||
res = ContextExternalTool.standardize_url(url)
|
||||
return false if res.blank?
|
||||
|
||||
if res.query.present?
|
||||
res.query = res.query.split("&").select { |p| @url_params.include?(p) }.sort.join("&")
|
||||
end
|
||||
res = Addressable::URI.parse(url).normalize
|
||||
res.query = res.query.split("&").select { |p| @url_params.include?(p) }.sort.join("&") if res.query.present?
|
||||
res.query = nil if res.query.blank?
|
||||
|
||||
res.normalize!
|
||||
return true if res.to_s == tool_url
|
||||
res == tool_url
|
||||
end
|
||||
end
|
||||
|
||||
|
@ -932,20 +938,20 @@ class ContextExternalTool < ActiveRecord::Base
|
|||
return false if domain.blank?
|
||||
|
||||
url = ContextExternalTool.standardize_url(url)
|
||||
host = Addressable::URI.parse(url).normalize.host rescue nil
|
||||
port = Addressable::URI.parse(url).normalize.port rescue nil
|
||||
host = url&.host
|
||||
port = url&.port
|
||||
d = domain.downcase.gsub(%r{https?://}, "")
|
||||
!!(host && ("." + host + (port ? ":#{port}" : "")).match(/\.#{d}\z/))
|
||||
end
|
||||
|
||||
def matches_domain?(url, use_environment_overrides: false)
|
||||
url = ContextExternalTool.standardize_url(url)
|
||||
host = Addressable::URI.parse(url).host
|
||||
host = ContextExternalTool.standardize_url(url)&.host
|
||||
domain = use_environment_overrides ? domain_with_environment_overrides : self.domain
|
||||
standard_url = standard_url(use_environment_overrides)
|
||||
if domain
|
||||
domain.casecmp?(host)
|
||||
elsif standard_url(use_environment_overrides)
|
||||
Addressable::URI.parse(standard_url(use_environment_overrides)).host == host
|
||||
domain == host
|
||||
elsif standard_url.present?
|
||||
standard_url.host == host
|
||||
else
|
||||
false
|
||||
end
|
||||
|
@ -1098,46 +1104,7 @@ class ContextExternalTool < ActiveRecord::Base
|
|||
)
|
||||
|
||||
# Check for a tool that exactly matches the given URL
|
||||
match = find_tool_match(
|
||||
sorted_external_tools,
|
||||
->(t) { t.matches_url?(url) },
|
||||
->(t) { t.url.present? }
|
||||
)
|
||||
|
||||
# If exactly match doesn't work, try to match by ignoring extra query parameters
|
||||
match ||= find_tool_match(
|
||||
sorted_external_tools,
|
||||
->(t) { t.matches_url?(url, false) },
|
||||
->(t) { t.url.present? }
|
||||
)
|
||||
|
||||
# If still no matches, use domain matching to try to find a tool
|
||||
match ||= find_tool_match(
|
||||
sorted_external_tools,
|
||||
->(t) { t.matches_tool_domain?(url) },
|
||||
->(t) { t.domain.present? }
|
||||
)
|
||||
|
||||
# repeat matches with environment-specific url and domain overrides
|
||||
if ApplicationController.test_cluster? && Account.site_admin.feature_enabled?(:dynamic_lti_environment_overrides)
|
||||
match ||= find_tool_match(
|
||||
sorted_external_tools,
|
||||
->(t) { t.matches_url?(url, use_environment_overrides: true) },
|
||||
->(t) { t.url.present? }
|
||||
)
|
||||
|
||||
match ||= find_tool_match(
|
||||
sorted_external_tools,
|
||||
->(t) { t.matches_url?(url, false, use_environment_overrides: true) },
|
||||
->(t) { t.url.present? }
|
||||
)
|
||||
|
||||
match ||= find_tool_match(
|
||||
sorted_external_tools,
|
||||
->(t) { t.matches_tool_domain?(url, use_environment_overrides: true) },
|
||||
->(t) { t.domain.present? }
|
||||
)
|
||||
end
|
||||
match = find_matching_tool(url, sorted_external_tools)
|
||||
|
||||
# always use the preferred tool id *unless* the preferred tool is a 1.1 tool
|
||||
# and the matched tool is a 1.3 tool, since 1.3 is the preferred version of a tool
|
||||
|
@ -1243,6 +1210,51 @@ class ContextExternalTool < ActiveRecord::Base
|
|||
end
|
||||
end
|
||||
|
||||
def self.find_matching_tool(url, sorted_external_tools)
|
||||
# Check for a tool that exactly matches the given URL
|
||||
match = find_tool_match(
|
||||
sorted_external_tools,
|
||||
->(t) { t.matches_url?(url) },
|
||||
->(t) { t.url.present? }
|
||||
)
|
||||
|
||||
# If exactly match doesn't work, try to match by ignoring extra query parameters
|
||||
match ||= find_tool_match(
|
||||
sorted_external_tools,
|
||||
->(t) { t.matches_url?(url, false) },
|
||||
->(t) { t.url.present? }
|
||||
)
|
||||
|
||||
# If still no matches, use domain matching to try to find a tool
|
||||
match ||= find_tool_match(
|
||||
sorted_external_tools,
|
||||
->(t) { t.matches_tool_domain?(url) },
|
||||
->(t) { t.domain.present? }
|
||||
)
|
||||
|
||||
# repeat matches with environment-specific url and domain overrides
|
||||
if ApplicationController.test_cluster? && Account.site_admin.feature_enabled?(:dynamic_lti_environment_overrides)
|
||||
match ||= find_tool_match(
|
||||
sorted_external_tools,
|
||||
->(t) { t.matches_url?(url, use_environment_overrides: true) },
|
||||
->(t) { t.url.present? }
|
||||
)
|
||||
|
||||
match ||= find_tool_match(
|
||||
sorted_external_tools,
|
||||
->(t) { t.matches_url?(url, false, use_environment_overrides: true) },
|
||||
->(t) { t.url.present? }
|
||||
)
|
||||
|
||||
match ||= find_tool_match(
|
||||
sorted_external_tools,
|
||||
->(t) { t.matches_tool_domain?(url, use_environment_overrides: true) },
|
||||
->(t) { t.domain.present? }
|
||||
)
|
||||
end
|
||||
match
|
||||
end
|
||||
|
||||
scope :having_setting, lambda { |setting|
|
||||
if setting
|
||||
joins(:context_external_tool_placements)
|
||||
|
@ -1539,6 +1551,44 @@ class ContextExternalTool < ActiveRecord::Base
|
|||
[Canvas::ICU.collation_key(name), global_id]
|
||||
end
|
||||
|
||||
def self.associated_1_1_tool(tool, context, launch_url)
|
||||
return nil unless launch_url && tool.use_1_3?
|
||||
|
||||
# Finding tools is expensive and this relationship doesn't change very often, so
|
||||
# it's worth it to maintain this possibly "incorrect" relationship for 5 minutes.
|
||||
id = Rails.cache.fetch([tool.global_asset_string, context.global_asset_string, launch_url.slice(0..1024)].cache_key, expires_in: 5.minutes) do
|
||||
# Rails themselves recommends against caching ActiveRecord models directly
|
||||
# https://guides.rubyonrails.org/caching_with_rails.html#avoid-caching-instances-of-active-record-objects
|
||||
GuardRail.activate(:secondary) do
|
||||
sorted_external_tools = context.shard.activate do
|
||||
contexts = contexts_to_search(context)
|
||||
context_order = contexts.map.with_index { |c, i| "(#{c.id},'#{c.class.polymorphic_name}',#{i})" }.join(",")
|
||||
|
||||
order_clauses = [
|
||||
# prefer tools that are not duplicates
|
||||
sort_by_sql_string("identity_hash != 'duplicate'"),
|
||||
# prefer tools from closer contexts
|
||||
"context_order.ordering",
|
||||
# prefer tools with more subdomains
|
||||
precedence_sql_string
|
||||
]
|
||||
query = ContextExternalTool.where(context: contexts, lti_version: "1.1")
|
||||
query.joins(sanitize_sql("INNER JOIN (values #{context_order}) as context_order (context_id, class, ordering)
|
||||
ON #{quoted_table_name}.context_id = context_order.context_id AND #{quoted_table_name}.context_type = context_order.class"))
|
||||
.order(Arel.sql(sanitize_sql_for_order(order_clauses.join(","))))
|
||||
end
|
||||
|
||||
find_matching_tool(launch_url, sorted_external_tools)&.id
|
||||
end
|
||||
end
|
||||
|
||||
ContextExternalTool.find_by(id:)
|
||||
end
|
||||
|
||||
def associated_1_1_tool(context, launch_url = nil)
|
||||
ContextExternalTool.associated_1_1_tool(self, context, launch_url || url || domain)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
# Locally and in OSS installations, this can be configured in config/dynamic_settings.yml.
|
||||
|
|
|
@ -183,6 +183,19 @@ disable_oembed_retrieve:
|
|||
If enabled, the deprecated oembed_retrieve endpoint will be unavailable.
|
||||
This is to ease transitioning any potential users off this endpoint before
|
||||
removing it altogether.
|
||||
include_oauth_consumer_key_in_lti_launch:
|
||||
state: hidden
|
||||
applies_to: SiteAdmin
|
||||
display_name: Enable Sending OAuth Consumer Key
|
||||
environments:
|
||||
development:
|
||||
state: allowed_on
|
||||
ci:
|
||||
state: allowed_on
|
||||
description: |-
|
||||
When enabled, LTI 1.3 launches that would be associated with LTI 1.1 tool
|
||||
if the LTI 1.3 tool wasn't present will include the oauth_consumer_key and
|
||||
oauth_consumer_key_sign properties as part of the LTI 1.1 claims section.
|
||||
resource_link_uuid_in_custom_substitution:
|
||||
state: hidden
|
||||
display_name: Use a Resource link's resource_link_uuid in the $ResourceLink.id substitution parameter
|
||||
|
|
|
@ -180,16 +180,24 @@ module CanvasSecurity
|
|||
end
|
||||
|
||||
def self.sign_hmac_sha512(string_to_sign, signing_secret = services_signing_secret)
|
||||
OpenSSL::HMAC.digest("sha512", signing_secret, string_to_sign)
|
||||
sign_hmac(string_to_sign, signing_secret, "sha512")
|
||||
end
|
||||
|
||||
def self.verify_hmac_sha512(message, signature, signing_secret = services_signing_secret)
|
||||
verify_hmac(message, signature, signing_secret, "sha512")
|
||||
end
|
||||
|
||||
def self.sign_hmac(string_to_sign, signing_secret = services_signing_secret, hashing_alg = "sha512")
|
||||
OpenSSL::HMAC.digest(hashing_alg, signing_secret, string_to_sign)
|
||||
end
|
||||
|
||||
def self.verify_hmac(message, signature, signing_secret = services_signing_secret, hashing_alg = "sha512")
|
||||
secrets_to_check = [signing_secret]
|
||||
if signing_secret == services_signing_secret && services_previous_signing_secret
|
||||
secrets_to_check << services_previous_signing_secret
|
||||
end
|
||||
secrets_to_check.each do |cur_secret|
|
||||
comparison = sign_hmac_sha512(message, cur_secret)
|
||||
comparison = sign_hmac(message, cur_secret, hashing_alg)
|
||||
return true if ActiveSupport::SecurityUtils.secure_compare(signature, comparison)
|
||||
end
|
||||
false
|
||||
|
|
|
@ -27,7 +27,7 @@ module LtiAdvantage::Claims
|
|||
class Lti1p1
|
||||
include ActiveModel::Model
|
||||
|
||||
attr_accessor :user_id, :resource_link_id
|
||||
attr_accessor :user_id, :resource_link_id, :oauth_consumer_key, :oauth_consumer_key_sign
|
||||
|
||||
validates_presence_of :user_id
|
||||
end
|
||||
|
|
|
@ -0,0 +1,37 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
#
|
||||
# Copyright (C) 2023 - present Instructure, Inc.
|
||||
#
|
||||
# This file is part of Canvas.
|
||||
#
|
||||
# Canvas is free software: you can redistribute it and/or modify it under
|
||||
# the terms of the GNU Affero General Public License as published by the Free
|
||||
# Software Foundation, version 3 of the License.
|
||||
#
|
||||
# Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
|
||||
# A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
|
||||
# details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License along
|
||||
# with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
# Separates out more complicated functionality from the JwtMessage class to make for
|
||||
# easier testing, separation of concerns, and just general understanding.
|
||||
module Lti::Helpers::JwtMessageHelper
|
||||
# Following the spec https://www.imsglobal.org/spec/lti/v1p3/migr#oauth_consumer_key_sign
|
||||
# This value MAY be included by a platform. However, it is recommended.
|
||||
def self.generate_oauth_consumer_key_sign(associated_1_1_tool, message)
|
||||
return nil if associated_1_1_tool.blank?
|
||||
|
||||
CanvasSecurity.base64_encode(CanvasSecurity.sign_hmac([associated_1_1_tool.consumer_key,
|
||||
message.deployment_id,
|
||||
message.iss,
|
||||
message.aud,
|
||||
message.exp,
|
||||
message.nonce].join("&").encode("utf-8"),
|
||||
associated_1_1_tool.shared_secret.to_s.encode("utf-8"),
|
||||
"sha256"))
|
||||
end
|
||||
end
|
|
@ -174,6 +174,10 @@ module Lti::Messages
|
|||
|
||||
def add_lti1p1_claims!
|
||||
@message.lti1p1.user_id = @user&.lti_context_id
|
||||
if associated_1_1_tool.present?
|
||||
@message.lti1p1.oauth_consumer_key = associated_1_1_tool.consumer_key
|
||||
@message.lti1p1.oauth_consumer_key_sign = Lti::Helpers::JwtMessageHelper.generate_oauth_consumer_key_sign(associated_1_1_tool, @message)
|
||||
end
|
||||
end
|
||||
|
||||
# Following the spec https://www.imsglobal.org/spec/lti/v1p3/migr#remapping-parameters
|
||||
|
@ -181,7 +185,9 @@ module Lti::Messages
|
|||
# platform MUST include the parameter and its LTI 1.1 value. Otherwise the
|
||||
# platform MAY omit that attribute.
|
||||
def include_lti1p1_claims?
|
||||
@user&.lti_context_id && @user.lti_context_id != @user.lti_id
|
||||
user_ids_differ = @user&.lti_context_id && @user.lti_context_id != @user.lti_id
|
||||
|
||||
user_ids_differ || associated_1_1_tool.present?
|
||||
end
|
||||
|
||||
# Follows the spec at https://www.imsglobal.org/spec/lti-ags/v2p0/#assignment-and-grade-service-claim
|
||||
|
@ -207,6 +213,12 @@ module Lti::Messages
|
|||
end
|
||||
end
|
||||
|
||||
def associated_1_1_tool
|
||||
return nil unless Account.site_admin.feature_enabled?(:include_oauth_consumer_key_in_lti_launch)
|
||||
|
||||
@associated_1_1_tool ||= @tool&.associated_1_1_tool(@context, target_link_uri)
|
||||
end
|
||||
|
||||
# Used to construct URLs for AGS endpoints like line item index, or line item show
|
||||
# assumes @context is either Group or Course, per #include_assignment_and_grade_service_claims?
|
||||
def course_id_for_ags_url
|
||||
|
|
|
@ -0,0 +1,46 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
#
|
||||
# Copyright (C) 2018 - present Instructure, Inc.
|
||||
#
|
||||
# This file is part of Canvas.
|
||||
#
|
||||
# Canvas is free software: you can redistribute it and/or modify it under
|
||||
# the terms of the GNU Affero General Public License as published by the Free
|
||||
# Software Foundation, version 3 of the License.
|
||||
#
|
||||
# Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
|
||||
# A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
|
||||
# details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License along
|
||||
# with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
describe Lti::Helpers::JwtMessageHelper do
|
||||
# This set of tests uses data directly from the spec, to ensure our signature generation algorithm
|
||||
# follows it precisely
|
||||
describe "generate_oauth_consumer_key_sign" do
|
||||
subject { Lti::Helpers::JwtMessageHelper.generate_oauth_consumer_key_sign(associated_1_1_tool, message) }
|
||||
|
||||
let(:associated_1_1_tool) do
|
||||
t = double("associated_1_1_tool")
|
||||
allow(t).to receive(:consumer_key).and_return("179248902")
|
||||
allow(t).to receive(:shared_secret).and_return("my-lti11-secret")
|
||||
t
|
||||
end
|
||||
let(:message) do
|
||||
m = double("message")
|
||||
allow(m).to receive(:deployment_id).and_return("689302")
|
||||
allow(m).to receive(:iss).and_return("https://lmsvendor.com")
|
||||
allow(m).to receive(:aud).and_return("PM48OJSfGDTAzAo")
|
||||
allow(m).to receive(:exp).and_return(1_551_290_856)
|
||||
allow(m).to receive(:nonce).and_return("172we8671fd8z")
|
||||
m
|
||||
end
|
||||
|
||||
it "generates the appropriate signature according to the IMS spec" do
|
||||
expect(subject).to eq("lWd54kFo5qU7xshAna6v8BwoBm6tmUjc6GTax6+12ps=")
|
||||
end
|
||||
end
|
||||
end
|
|
@ -1011,23 +1011,66 @@ describe Lti::Messages::JwtMessage do
|
|||
end
|
||||
|
||||
describe "lti1p1 claims" do
|
||||
subject { decoded_jwt[lti1p1_claim] }
|
||||
|
||||
let(:lti1p1_claim) { "https://purl.imsglobal.org/spec/lti/claim/lti1p1" }
|
||||
|
||||
context "when user does not have lti_context_id" do
|
||||
subject { decoded_jwt }
|
||||
|
||||
before do
|
||||
allow(user).to receive(:lti_context_id).and_return(nil)
|
||||
end
|
||||
|
||||
it "does not include the claim" do
|
||||
expect(decoded_jwt).to_not include lti1p1_claim
|
||||
end
|
||||
it { is_expected.not_to include lti1p1_claim }
|
||||
end
|
||||
|
||||
context "when user has lti_context_id" do
|
||||
let(:message_lti1p1) { decoded_jwt[lti1p1_claim] }
|
||||
|
||||
it "adds user_id" do
|
||||
expect(message_lti1p1["user_id"]).to eq user.lti_context_id
|
||||
expect(subject["user_id"]).to eq user.lti_context_id
|
||||
end
|
||||
end
|
||||
|
||||
context "when there is an associated LTI 1.1 tool" do
|
||||
let!(:associated_1_1_tool) { external_tool_model(context: course, opts: { url: "http://www.example.com/basic_lti" }) }
|
||||
|
||||
before do
|
||||
allow(Lti::Helpers::JwtMessageHelper).to receive(:generate_oauth_consumer_key_sign).and_return("avalidsignature")
|
||||
end
|
||||
|
||||
context "the include_oauth_consumer_key_in_lti_launch flag is enabled" do
|
||||
before do
|
||||
Account.site_admin.enable_feature!(:include_oauth_consumer_key_in_lti_launch)
|
||||
end
|
||||
|
||||
it "includes the oauth_consumer_key related claims" do
|
||||
expect(subject["oauth_consumer_key"]).to eq associated_1_1_tool.consumer_key
|
||||
expect(subject["oauth_consumer_key_sign"]).to eq "avalidsignature"
|
||||
end
|
||||
end
|
||||
|
||||
context "the include_oauth_consumer_key_in_lti_launch flag is disabled" do
|
||||
before do
|
||||
Account.site_admin.disable_feature!(:include_oauth_consumer_key_in_lti_launch)
|
||||
end
|
||||
|
||||
it "doesn't include the oauth_consumer_key related claims" do
|
||||
expect(subject).not_to include "oauth_consumer_key"
|
||||
expect(subject).not_to include "oauth_consumer_key_sign"
|
||||
end
|
||||
|
||||
it "doesn't attempt to perform any lookups" do
|
||||
expect_any_instance_of(ContextExternalTool).not_to receive(:associated_1_1_tool)
|
||||
expect(subject).not_to include "oauth_consumer_key"
|
||||
expect(subject).not_to include "oauth_consumer_key_sign"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context "when there isn't an associated LTI 1.1 tool" do
|
||||
it "doesn't include the oauth_consumer_key related claims" do
|
||||
expect(subject).not_to include "oauth_consumer_key"
|
||||
expect(subject).not_to include "oauth_consumer_key_sign"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -2743,22 +2743,22 @@ describe ContextExternalTool do
|
|||
end
|
||||
|
||||
it "handles spaces in front of url" do
|
||||
url = ContextExternalTool.standardize_url(" http://sub_underscore.google.com?a=1&b=2")
|
||||
url = ContextExternalTool.standardize_url(" http://sub_underscore.google.com?a=1&b=2").to_s
|
||||
expect(url).to eql("http://sub_underscore.google.com/?a=1&b=2")
|
||||
end
|
||||
|
||||
it "handles tabs in front of url" do
|
||||
url = ContextExternalTool.standardize_url("\thttp://sub_underscore.google.com?a=1&b=2")
|
||||
url = ContextExternalTool.standardize_url("\thttp://sub_underscore.google.com?a=1&b=2").to_s
|
||||
expect(url).to eql("http://sub_underscore.google.com/?a=1&b=2")
|
||||
end
|
||||
|
||||
it "handles unicode whitespace" do
|
||||
url = ContextExternalTool.standardize_url("\u00A0http://sub_underscore.go\u2005ogle.com?a=1\u2002&b=2")
|
||||
url = ContextExternalTool.standardize_url("\u00A0http://sub_underscore.go\u2005ogle.com?a=1\u2002&b=2").to_s
|
||||
expect(url).to eql("http://sub_underscore.google.com/?a=1&b=2")
|
||||
end
|
||||
|
||||
it "handles underscores in the domain" do
|
||||
url = ContextExternalTool.standardize_url("http://sub_underscore.google.com?a=1&b=2")
|
||||
url = ContextExternalTool.standardize_url("http://sub_underscore.google.com?a=1&b=2").to_s
|
||||
expect(url).to eql("http://sub_underscore.google.com/?a=1&b=2")
|
||||
end
|
||||
end
|
||||
|
@ -3670,4 +3670,69 @@ describe ContextExternalTool do
|
|||
expect([sk1, sk2, sk3].sort).to eq([sk1, sk3, sk2])
|
||||
end
|
||||
end
|
||||
|
||||
describe "associated_1_1_tool" do
|
||||
specs_require_cache(:redis_cache_store)
|
||||
|
||||
subject { lti_1_3_tool.associated_1_1_tool(context, requested_url) }
|
||||
|
||||
let(:context) { @course }
|
||||
let(:domain) { "test.com" }
|
||||
let(:opts) { { url:, domain: } }
|
||||
let(:requested_url) { nil }
|
||||
let(:url) { "https://test.com/foo?bar=1" }
|
||||
let!(:lti_1_1_tool) { external_tool_model(context:, opts:) }
|
||||
let!(:lti_1_3_tool) { external_tool_1_3_model(context:, opts:) }
|
||||
|
||||
it { is_expected.to eq lti_1_1_tool }
|
||||
|
||||
it "caches the result" do
|
||||
expect(subject).to eq lti_1_1_tool
|
||||
|
||||
allow(ContextExternalTool).to receive(:find_external_tool)
|
||||
lti_1_3_tool.associated_1_1_tool(context)
|
||||
expect(ContextExternalTool).not_to have_received(:find_external_tool)
|
||||
end
|
||||
|
||||
it "finds deleted 1.1 tools" do
|
||||
lti_1_1_tool.destroy
|
||||
expect(subject).to eq(lti_1_1_tool)
|
||||
end
|
||||
|
||||
it "finds nil and doesn't error on tools with invalid URL & Domains" do
|
||||
lti_1_1_tool.update_column(:url, "http://url path>/invalidurl}")
|
||||
lti_1_1_tool.update_column(:domain, "url path>/invalidurl}")
|
||||
|
||||
expect { subject }.not_to raise_error
|
||||
expect(subject).to be_nil
|
||||
end
|
||||
|
||||
it "finds tools in a higher level context" do
|
||||
lti_1_1_tool.update!(context: context.account)
|
||||
expect(subject).to eq(lti_1_1_tool)
|
||||
end
|
||||
|
||||
it "ignores duplicate tools" do
|
||||
lti_1_1_tool.dup.save!
|
||||
expect(subject).to eq(lti_1_1_tool)
|
||||
end
|
||||
|
||||
context "the request is to a subdomain of the tools' domain" do
|
||||
let(:requested_url) { "https://morespecific.test.com/foo?bar=1" }
|
||||
|
||||
it { is_expected.to eq(lti_1_1_tool) }
|
||||
|
||||
context "there's another 1.1 tool with that subdomain" do
|
||||
let(:specific_opts) do
|
||||
{
|
||||
url: "https://morespecific.test.com/foo?bar=1",
|
||||
domain: "https://morespecific.test.com"
|
||||
}
|
||||
end
|
||||
let!(:specific_1_1_tool) { external_tool_model(context:, opts: specific_opts) }
|
||||
|
||||
it { is_expected.to eq(specific_1_1_tool) }
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
Loading…
Reference in New Issue