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:
Dan McCallum 2018-11-01 20:24:40 -07:00 committed by Marc Phillips
parent 3802b89590
commit ecd0e98dea
8 changed files with 167 additions and 45 deletions

View File

@ -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))

View File

@ -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)

View File

@ -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

View File

@ -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

View File

@ -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 }

View File

@ -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

View File

@ -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

View File

@ -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