Launch LTI 1.3 tools with Assignment-derived resource link IDs
- Previously all LTI 1.3 launches set the context's identifier as the resource link ID. Now, when launching an `Assignment` bound to a LTI 1.3 tool, the `Assignment`s LTI identifier is set as the resource link ID. (Actually it's the LTI identifier associated with the `Assignment`'s `ResourceLink` for the launching tool.) - Any type of mismatch between the launching tool and the tool associated with the `Assignment` or its `ResourceLink` results in an error. As noted at bottom of the test plan, that the possibility for such errors even exists is rather likely to change in the future. Closes LTIA-48 Test Plan: 1. Create an LTI 1.3 `ContextExternalTool` and make it available to a `Course`. Be sure to include a course navigation placement config. 2. Create an `Assignment` in that `Course` and bind it to the `ContextExternalTool` created in step 1. 3. Launch the `Assignment`. Verify claim: ``` "https://purl.imsglobal.org/spec/lti/claim/resource_link": { "id": ${assignment.line_items.find(&:assignment_line_item?).resource_link.resource_link_id} }, ``` 4. Verify that the claim value in step 4 does _not_ match the `https://purl.imsglobal.org/spec/lti/claim/context` -> `id` claim (the latter should be the LTI ID for the `Course`). 5. Launch the tool from course navigation. 6. Verify that the `https://purl.imsglobal.org/spec/lti/claim/context` -> `id` claim has the same value it did in step 4. 7. Verify that the `https://purl.imsglobal.org/spec/lti/claim/context` -> `id` and `https://purl.imsglobal.org/spec/lti/claim/resource_link` -> `id` claims match 8. Re-bind the `Assignment` to a different LTI 1.3 `ContextExternalTool`. 9. Verify that launch attempts for the `Assignment` now fail with a `Lti::Ims::AdvantageErrors::InvalidLaunchError :: Mismatched assignment vs resource link tool configurations` message in error logs. NB the last step is almost certainly incorrect long-term behavior, but is consistent with the non-editability of `Assignment` `LineItem`s and `ResourceLinks` from LTIA-47. Change-Id: Ie5c63430082c4465a7d943343941f931c968ae11 Reviewed-on: https://gerrit.instructure.com/170818 Tested-by: Jenkins Reviewed-by: Weston Dransfield <wdransfield@instructure.com> QA-Review: Marc Phillips <mphillips@instructure.com> Product-Review: Marc Phillips <mphillips@instructure.com>
This commit is contained in:
parent
3802b89590
commit
ecd0e98dea
|
@ -39,6 +39,12 @@ module Lti::Ims
|
|||
end
|
||||
end
|
||||
|
||||
class InvalidLaunchError < AdvantageClientError
|
||||
def initialize(msg=nil, opts={})
|
||||
super(msg, { api_message: 'Invalid LTI launch attempt', status_code: :bad_request }.merge(opts))
|
||||
end
|
||||
end
|
||||
|
||||
class AdvantageSecurityError < AdvantageClientError
|
||||
def initialize(msg=nil, opts={})
|
||||
super(msg, { api_message:'Service invocation refused', status_code: :unauthorized }.merge(opts))
|
||||
|
|
|
@ -29,6 +29,7 @@ module Lti::Messages
|
|||
@expander = expander
|
||||
@return_url = return_url
|
||||
@message = LtiAdvantage::Messages::JwtMessage.new
|
||||
@used = false
|
||||
end
|
||||
|
||||
def self.generate_id_token(body)
|
||||
|
@ -36,6 +37,9 @@ module Lti::Messages
|
|||
end
|
||||
|
||||
def generate_post_payload_message
|
||||
raise 'Class can only be used once.' if @used
|
||||
@used = true
|
||||
|
||||
add_security_claims! if include_claims?(:security)
|
||||
add_public_claims! if @tool.public? && include_claims?(:public)
|
||||
add_mentorship_claims! if @tool.public? && include_claims?(:mentorship)
|
||||
|
|
|
@ -36,7 +36,7 @@ module Lti::Messages
|
|||
add_extension('outcome_result_total_score_accepted', true)
|
||||
add_extension('outcome_submission_submitted_at_accepted', true)
|
||||
add_extension('outcomes_tool_placement_url', lti_turnitin_outcomes_placement_url)
|
||||
add_assignment_substitutions!
|
||||
add_assignment_substitutions!(assignment)
|
||||
generate_post_payload
|
||||
end
|
||||
|
||||
|
@ -44,20 +44,40 @@ module Lti::Messages
|
|||
lti_assignment = Lti::LtiAssignmentCreator.new(assignment).convert
|
||||
add_extension('content_return_types', lti_assignment.return_types.join(','))
|
||||
add_extension('content_file_extensions', assignment.allowed_extensions&.join(','))
|
||||
add_assignment_substitutions!
|
||||
add_assignment_substitutions!(assignment)
|
||||
generate_post_payload
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def add_resource_link_request_claims!
|
||||
@message.resource_link.id = Lti::Asset.opaque_identifier_for(@context)
|
||||
@message.resource_link.id = @opts[:resource_link_id] || context_resource_link_id
|
||||
end
|
||||
|
||||
def add_assignment_substitutions!
|
||||
def context_resource_link_id
|
||||
Lti::Asset.opaque_identifier_for(@context)
|
||||
end
|
||||
|
||||
def assignment_resource_link_id(assignment)
|
||||
launch_error = Lti::Ims::AdvantageErrors::InvalidLaunchError
|
||||
unless assignment.external_tool?
|
||||
raise launch_error.new(nil, api_message: 'Assignment not configured for external tool launches')
|
||||
end
|
||||
unless assignment.external_tool_tag&.content == @tool
|
||||
raise launch_error.new(nil, api_message: 'Assignment not configured for launches with specified tool')
|
||||
end
|
||||
resource_link = assignment.line_items.find(&:assignment_line_item?)&.resource_link
|
||||
unless resource_link&.context_external_tool == @tool
|
||||
raise launch_error.new(nil, api_message: 'Mismatched assignment vs resource link tool configurations')
|
||||
end
|
||||
resource_link.resource_link_id
|
||||
end
|
||||
|
||||
def add_assignment_substitutions!(assignment)
|
||||
add_extension('canvas_assignment_id', '$Canvas.assignment.id') if @tool.public?
|
||||
add_extension('canvas_assignment_title', '$Canvas.assignment.title')
|
||||
add_extension('canvas_assignment_points_possible', '$Canvas.assignment.pointsPossible')
|
||||
@opts[:resource_link_id] = assignment_resource_link_id(assignment)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -604,6 +604,9 @@ RSpec.describe ApplicationController do
|
|||
tool.developer_key = developer_key
|
||||
tool.use_1_3 = true
|
||||
tool.save!
|
||||
|
||||
assignment = assignment_model(submission_types: 'external_tool', external_tool_tag: content_tag)
|
||||
content_tag.update_attributes!(context: assignment)
|
||||
end
|
||||
|
||||
shared_examples_for 'a placement that caches the launch' do
|
||||
|
@ -643,6 +646,7 @@ RSpec.describe ApplicationController do
|
|||
|
||||
it_behaves_like 'a placement that caches the launch'
|
||||
end
|
||||
# rubocop:enable RSpec/NestedGroups
|
||||
end
|
||||
|
||||
it 'creates a basic lti launch request when tool is not configured to use LTI 1.3' do
|
||||
|
|
|
@ -91,6 +91,13 @@ describe 'LTI Advantage Errors' do
|
|||
it_behaves_like 'error check'
|
||||
end
|
||||
|
||||
describe Lti::Ims::AdvantageErrors::InvalidLaunchError do
|
||||
let(:default_api_message) { 'Invalid LTI launch attempt' }
|
||||
let(:default_status_code) { :bad_request }
|
||||
|
||||
it_behaves_like 'error check'
|
||||
end
|
||||
|
||||
describe Lti::Ims::AdvantageErrors::AdvantageSecurityError do
|
||||
let(:default_api_message) { 'Service invocation refused' }
|
||||
let(:default_status_code) { :unauthorized }
|
||||
|
|
|
@ -23,7 +23,6 @@ describe Lti::Messages::JwtMessage do
|
|||
let(:return_url) { 'http://www.platform.com/return_url' }
|
||||
let(:user) { @student }
|
||||
let(:opts) { { resource_type: 'course_navigation' } }
|
||||
|
||||
let(:expander) do
|
||||
Lti::VariableExpander.new(
|
||||
course.root_account,
|
||||
|
@ -35,33 +34,18 @@ describe Lti::Messages::JwtMessage do
|
|||
}
|
||||
)
|
||||
end
|
||||
|
||||
let(:jwt_message) do
|
||||
Lti::Messages::JwtMessage.new(
|
||||
tool: tool,
|
||||
context: course,
|
||||
user: user,
|
||||
expander: expander,
|
||||
return_url: return_url,
|
||||
opts: opts
|
||||
)
|
||||
end
|
||||
|
||||
let(:decoded_jwt) do
|
||||
jws = Lti::Messages::JwtMessage.generate_id_token(jwt_message.generate_post_payload)
|
||||
JSON::JWT.decode(jws[:id_token], pub_key)
|
||||
end
|
||||
|
||||
let(:pub_key) do
|
||||
jwk = JSON::JWK.new(Lti::KeyStorage.retrieve_keys['jwk-present.json'])
|
||||
jwk.to_key.public_key
|
||||
end
|
||||
|
||||
let_once(:course) do
|
||||
course_with_student
|
||||
@course
|
||||
end
|
||||
|
||||
let_once(:assignment) { assignment_model(course: course) }
|
||||
let_once(:tool) do
|
||||
tool = course.context_external_tools.new(
|
||||
|
@ -87,6 +71,17 @@ describe Lti::Messages::JwtMessage do
|
|||
end
|
||||
let_once(:developer_key) { DeveloperKey.create! }
|
||||
|
||||
def jwt_message
|
||||
Lti::Messages::JwtMessage.new(
|
||||
tool: tool,
|
||||
context: course,
|
||||
user: user,
|
||||
expander: expander,
|
||||
return_url: return_url,
|
||||
opts: opts
|
||||
)
|
||||
end
|
||||
|
||||
describe 'signing' do
|
||||
it 'signs the id token with the current canvas private key' do
|
||||
jws = Lti::Messages::JwtMessage.generate_id_token(jwt_message.generate_post_payload)
|
||||
|
@ -847,7 +842,7 @@ describe Lti::Messages::JwtMessage do
|
|||
observer.update!(lti_context_id: SecureRandom.uuid)
|
||||
observer_enrollment = course.enroll_user(observer, 'ObserverEnrollment')
|
||||
observer_enrollment.update_attribute(:associated_user_id, user.id)
|
||||
allow(jwt_message).to receive(:current_observee_list).and_return([observer.lti_context_id])
|
||||
allow_any_instance_of(Lti::Messages::JwtMessage).to receive(:current_observee_list).and_return([observer.lti_context_id])
|
||||
|
||||
expect(decoded_jwt['https://purl.imsglobal.org/spec/lti/claim/role_scope_mentor']).to match_array [
|
||||
observer.lti_context_id
|
||||
|
|
|
@ -36,17 +36,13 @@ describe Lti::Messages::ResourceLinkRequest do
|
|||
}
|
||||
)
|
||||
end
|
||||
let(:jwt_message) do
|
||||
Lti::Messages::ResourceLinkRequest.new(
|
||||
tool: tool,
|
||||
context: course,
|
||||
user: user,
|
||||
expander: expander,
|
||||
return_url: return_url,
|
||||
opts: opts
|
||||
let(:assignment) do
|
||||
assignment_model(
|
||||
course: course,
|
||||
submission_types: 'external_tool',
|
||||
external_tool_tag_attributes: { content: tool }
|
||||
)
|
||||
end
|
||||
let_once(:assignment) { assignment_model(course: course) }
|
||||
let_once(:user) { user_model(email: 'banana@test.com') }
|
||||
let_once(:course) do
|
||||
course_with_student
|
||||
|
@ -75,6 +71,14 @@ describe Lti::Messages::ResourceLinkRequest do
|
|||
tool
|
||||
end
|
||||
|
||||
shared_examples 'disabled rlid claim group check' do
|
||||
let(:opts) { super().merge({claim_group_blacklist: [:rlid]}) }
|
||||
|
||||
it 'does not set the resource link id' do
|
||||
expect(jws).not_to include('https://purl.imsglobal.org/spec/lti/claim/resource_link')
|
||||
end
|
||||
end
|
||||
|
||||
describe '#initialize' do
|
||||
let(:jws) { jwt_message.generate_post_payload }
|
||||
|
||||
|
@ -120,16 +124,73 @@ describe Lti::Messages::ResourceLinkRequest do
|
|||
end
|
||||
|
||||
it 'sets the resource link id' do
|
||||
expect(jws.dig('https://purl.imsglobal.org/spec/lti/claim/resource_link', 'id')).to eq course.lti_context_id
|
||||
expect_course_resource_link_id(jws)
|
||||
end
|
||||
|
||||
context 'when rlid claim group disabled' do
|
||||
let(:opts) { super().merge({claim_group_blacklist: [:rlid]}) }
|
||||
it_behaves_like 'disabled rlid claim group check'
|
||||
end
|
||||
|
||||
it 'does not set the resource link id' do
|
||||
expect(jws).not_to include('https://purl.imsglobal.org/spec/lti/claim/resource_link')
|
||||
shared_examples 'assignment resource link id check' do
|
||||
let(:launch_error) { Lti::Ims::AdvantageErrors::InvalidLaunchError }
|
||||
let(:api_message) { raise 'set in example' }
|
||||
let(:course_jws) { jwt_message.generate_post_payload }
|
||||
|
||||
shared_examples 'launch error check' do
|
||||
it 'raises launch error' do
|
||||
expect { jws }.to raise_error(launch_error, "#{launch_error} :: #{api_message}") do |e|
|
||||
expect(e.api_message).to eq api_message
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
it 'sets the assignment as resource link id' do
|
||||
expect_assignment_resource_link_id(jws)
|
||||
end
|
||||
|
||||
context 'when assignment not configured for external tool lauch' do
|
||||
let(:api_message) { 'Assignment not configured for external tool launches' }
|
||||
|
||||
before do
|
||||
assignment.update_attributes!(submission_types: 'none')
|
||||
end
|
||||
|
||||
it_behaves_like 'launch error check'
|
||||
end
|
||||
|
||||
context 'when tool bindings are unexpected' do
|
||||
let(:different_tool) do
|
||||
tool = course.context_external_tools.new(
|
||||
name: 'bob2',
|
||||
consumer_key: 'key2',
|
||||
shared_secret: 'secret2',
|
||||
url: 'http://www.example2.com/basic_lti'
|
||||
)
|
||||
tool.save!
|
||||
tool
|
||||
end
|
||||
|
||||
context 'because the assignment tool binding does not match the launching tool' do
|
||||
let(:api_message) { 'Assignment not configured for launches with specified tool' }
|
||||
|
||||
before do
|
||||
assignment.update_attributes!(external_tool_tag_attributes: { content: different_tool })
|
||||
end
|
||||
|
||||
it_behaves_like 'launch error check'
|
||||
end
|
||||
|
||||
context 'because the resource link tool binding does not match the launching tool' do
|
||||
let(:api_message) { 'Mismatched assignment vs resource link tool configurations' }
|
||||
|
||||
before do
|
||||
assignment.line_items.find(&:assignment_line_item?).resource_link.update_attributes!(context_external_tool: different_tool)
|
||||
end
|
||||
|
||||
it_behaves_like 'launch error check'
|
||||
end
|
||||
end
|
||||
|
||||
it_behaves_like 'disabled rlid claim group check'
|
||||
end
|
||||
|
||||
describe '#generate_post_payload_for_assignment' do
|
||||
|
@ -191,14 +252,12 @@ describe Lti::Messages::ResourceLinkRequest do
|
|||
it 'adds the canvas_assignment_points_possible' do
|
||||
expect(jws['https://www.instructure.com/canvas_assignment_points_possible']).to eq assignment.points_possible
|
||||
end
|
||||
|
||||
it_behaves_like 'assignment resource link id check'
|
||||
end
|
||||
|
||||
describe '#generate_post_payload_for_homework_submission' do
|
||||
let(:jws) do
|
||||
jwt_message.generate_post_payload_for_homework_submission(
|
||||
assignment
|
||||
)
|
||||
end
|
||||
let(:jws) { jwt_message.generate_post_payload_for_homework_submission(assignment) }
|
||||
|
||||
it 'adds the content_return_types' do
|
||||
expect(jws['https://www.instructure.com/content_return_types']).to eq lti_assignment.return_types.join(',')
|
||||
|
@ -221,5 +280,27 @@ describe Lti::Messages::ResourceLinkRequest do
|
|||
it 'adds the canvas_assignment_points_possible' do
|
||||
expect(jws['https://www.instructure.com/canvas_assignment_points_possible']).to eq assignment.points_possible
|
||||
end
|
||||
|
||||
it_behaves_like 'assignment resource link id check'
|
||||
end
|
||||
|
||||
def jwt_message
|
||||
Lti::Messages::ResourceLinkRequest.new(
|
||||
tool: tool,
|
||||
context: course,
|
||||
user: user,
|
||||
expander: expander,
|
||||
return_url: return_url,
|
||||
opts: opts
|
||||
)
|
||||
end
|
||||
|
||||
def expect_assignment_resource_link_id(jws)
|
||||
rlid = assignment.line_items.first.resource_link.resource_link_id
|
||||
expect(jws.dig('https://purl.imsglobal.org/spec/lti/claim/resource_link', 'id')).to eq rlid
|
||||
end
|
||||
|
||||
def expect_course_resource_link_id(jws)
|
||||
expect(jws.dig('https://purl.imsglobal.org/spec/lti/claim/resource_link', 'id')).to eq course.lti_context_id
|
||||
end
|
||||
end
|
||||
|
|
|
@ -47,9 +47,6 @@ describe Lti::LtiAdvantageAdapter do
|
|||
opts: opts
|
||||
)
|
||||
end
|
||||
let(:login_message) { adapter.generate_post_payload }
|
||||
let(:verifier) { Canvas::Security.decode_jwt(login_message['lti_message_hint'])['verifier'] }
|
||||
let(:params) { JSON.parse(fetch_and_delete_launch(course, verifier)) }
|
||||
let(:tool) do
|
||||
tool = course.context_external_tools.new(
|
||||
name: 'bob',
|
||||
|
@ -63,8 +60,16 @@ describe Lti::LtiAdvantageAdapter do
|
|||
tool.save!
|
||||
tool
|
||||
end
|
||||
|
||||
let_once(:assignment) { assignment_model(course: course) }
|
||||
let(:login_message) { adapter.generate_post_payload }
|
||||
let(:verifier) { Canvas::Security.decode_jwt(login_message['lti_message_hint'])['verifier'] }
|
||||
let(:params) { JSON.parse(fetch_and_delete_launch(course, verifier)) }
|
||||
let(:assignment) do
|
||||
assignment_model(
|
||||
course: course,
|
||||
submission_types: 'external_tool',
|
||||
external_tool_tag_attributes: { content: tool }
|
||||
)
|
||||
end
|
||||
let_once(:course) do
|
||||
course_with_student
|
||||
@course
|
||||
|
|
Loading…
Reference in New Issue