LTI 1.3: Add security claims and sign with Canvas private key

Closes PLAT-3633, PLAT-3634

Test Plan:
- Do an LTI 1.3 launch in Canvas and verify the id token is
  signed with the current canvas secret key.
- Verify the following claims are included and correct:
  * exp
  * iat
  * iss
  * nonce
  * sub

Change-Id: I57699ac42bbe98a9fa03f82f3f9b9a16c6923011
Reviewed-on: https://gerrit.instructure.com/159855
Tested-by: Jenkins
Reviewed-by: Marc Alan Phillips <mphillips@instructure.com>
QA-Review: Marc Alan Phillips <mphillips@instructure.com>
Product-Review: Marc Alan Phillips <mphillips@instructure.com>
This commit is contained in:
wdransfield 2018-08-06 15:50:45 -06:00 committed by Weston Dransfield
parent a709c080cd
commit 59942f4ff8
14 changed files with 110 additions and 38 deletions

View File

@ -1,6 +1,7 @@
production:
# replace this with a random string of at least 20 characters
encryption_key: 12345
lti_iss: 'https://canvas.instructure.com'
development:
encryption_key: facdd3a131ddd8988b14f6e4e01039c93cfa0160

View File

@ -1,5 +1,6 @@
production: &default
encryption_key: <%= ENV["ENCRYPTION_KEY"] %>
lti_iss: 'https://canvas.instructure.com'
development:
<<: *default

View File

@ -49,7 +49,7 @@ module Lti
# Retrieve the public keys in JWK format
#
# @return [Hash] The hash of public key in JWK format
# @return [Array] The array of public keys in JWK format
def public_keyset
retrieve_keys.values.map do |private_jwk|
public_jwk = private_jwk.to_key.public_key.to_jwk
@ -57,6 +57,13 @@ module Lti
end
end
# Retrieve the present key
#
# @return [JSON::JWK] the present private key
def present_key
JSON::JWK.new(Lti::KeyStorage.retrieve_keys[PRESENT])
end
private
def initialize_keys

View File

@ -40,19 +40,18 @@ module Lti::Messages
add_private_claims!
@expander.expand_variables!(@message.extensions)
{ id_token: @message.to_jws(OpenSSL::PKey::RSA.new(1024)) }
{ id_token: @message.to_jws(Lti::KeyStorage.present_key) }
end
private
def add_security_claims!
@message.aud = 'TODO: aud'
@message.azp = 'TODO: azp'
@message.deployment_id = 'TODO: deployment id'
@message.exp = 'TODO: exp'
@message.aud = "TODO: Client ID"
@message.deployment_id = 'TODO: Deployment ID'
@message.exp = Setting.get('lti.oauth2.access_token.exp', 1.hour).to_i.seconds.from_now.to_i
@message.iat = Time.zone.now.to_i
@message.iss = 'TODO: iss'
@message.nonce = 'TODO: nonce'
@message.iss = Canvas::Security.config['lti_iss']
@message.nonce = SecureRandom.uuid
@message.sub = Lti::Asset.opaque_identifier_for(@user)
end

View File

@ -20,7 +20,7 @@ require 'openssl'
module Lti
class RSAKeyPair < JWKKeyPair
ALG = 'RS256'.freeze
SIZE = 256
SIZE = 2048
def initialize(use: 'sig')
@alg = ALG
@use = use

View File

@ -16,18 +16,13 @@
# with this program. If not, see <http://www.gnu.org/licenses/>.
require File.expand_path(File.dirname(__FILE__) + '/../../api_spec_helper')
require File.expand_path(File.dirname(__FILE__) + '/../../../lti_1_3_spec_helper')
require_dependency "lti/ims/security_controller"
module Lti::Ims
RSpec.describe SecurityController, type: :request do
before do
@fallback_proxy = Canvas::DynamicSettings::FallbackProxy.new({
Lti::KeyStorage::PAST => Lti::RSAKeyPair.new.to_jwk.to_json,
Lti::KeyStorage::PRESENT => Lti::RSAKeyPair.new.to_jwk.to_json,
Lti::KeyStorage::FUTURE => Lti::RSAKeyPair.new.to_jwk.to_json
})
allow(Canvas::DynamicSettings).to receive(:kv_proxy).and_return(@fallback_proxy)
end
include_context 'lti_1_3_spec_helper'
let(:url) { Rails.application.routes.url_helpers.jwks_show_path }
let(:json) { JSON.parse(response.body) }

View File

@ -17,6 +17,7 @@
#
require File.expand_path(File.dirname(__FILE__) + '/../sharding_spec_helper')
require File.expand_path(File.dirname(__FILE__) + '/../lti_1_3_spec_helper')
describe ApplicationController do
@ -545,6 +546,8 @@ describe ApplicationController do
end
describe 'LTI 1.3' do
include_context 'lti_1_3_spec_helper'
before do
tool.settings['use_1_3'] = true
tool.save!

View File

@ -17,6 +17,7 @@
#
require File.expand_path(File.dirname(__FILE__) + '/../spec_helper')
require File.expand_path(File.dirname(__FILE__) + '/../lti_1_3_spec_helper')
describe ExternalToolsController do
include ExternalToolsSpecHelper
@ -96,6 +97,8 @@ describe ExternalToolsController do
describe "GET 'show'" do
context 'resource link request' do
include_context 'lti_1_3_spec_helper'
let(:tool) do
tool = @course.context_external_tools.new(
name: "bob",

View File

@ -15,9 +15,11 @@
# 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/>.
require File.expand_path(File.dirname(__FILE__) + '/../../../spec_helper.rb')
require File.expand_path(File.dirname(__FILE__) + '/../../../lti_1_3_spec_helper')
describe Lti::Messages::JwtMessage do
include_context 'lti_1_3_spec_helper'
let(:return_url) { 'http://www.platform.com/return_url' }
let(:user) { @student }
let(:opts) { { resource_type: 'course_navigation' } }
@ -69,7 +71,12 @@ describe Lti::Messages::JwtMessage do
let(:decoded_jwt) do
jws = jwt_message.generate_post_payload
JSON::JWT.decode(jws[:id_token], :skip_verification)
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
@ -79,37 +86,57 @@ describe Lti::Messages::JwtMessage do
let_once(:assignment) { assignment_model(course: course) }
xdescribe '#add_security_claims!' do
it 'sets the "aud" claim' do
describe 'signing' do
it 'signs the id token with the current canvas private key' do
jws = jwt_message.generate_post_payload
expect do
JSON::JWT.decode(jws[:id_token], pub_key)
end.not_to raise_exception
end
end
describe 'security claims' do
xit 'sets the "aud" claim' do
expect(decoded_jwt.aud).to eq ''
end
it 'sets the "azp" claim' do
xit 'sets the "azp" claim' do
expect(decoded_jwt.azp).to eq ''
end
it 'sets the "deployment_id" claim' do
xit 'sets the "deployment_id" claim' do
expect(decoded_jwt.deployment_id).to eq ''
end
it 'sets the "exp" claim' do
expect(decoded_jwt.exp).to eq ''
it 'sets the "exp" claim to lti.oauth2.access_token.exp' do
Timecop.freeze do
expect(decoded_jwt['exp']).to eq Setting.get('lti.oauth2.access_token.exp', 1.hour).to_i.seconds.from_now.to_i
end
end
it 'sets the "iat" claim' do
expect(decoded_jwt.iat).to eq ''
it 'sets the "iat" claim to the current time' do
Timecop.freeze do
expect(decoded_jwt['iat']).to eq Time.zone.now.to_i
end
end
it 'sets the "iss" claim' do
expect(decoded_jwt.iss).to eq ''
it 'sets the "iss" to "https://canvas.instructure.com"' do
config = "test:\n lti_iss: 'https://canvas.instructure.com'"
allow(Canvas::Security).to receive(:config).and_return(YAML.safe_load(config)[Rails.env])
expect(decoded_jwt['iss']).to eq 'https://canvas.instructure.com'
end
it 'sets the "nonce" claim' do
expect(decoded_jwt.nonce).to eq ''
it 'sets the "nonce" claim to a unique ID' do
first_nonce = decoded_jwt['nonce']
jws = jwt_message.generate_post_payload
second_nonce = JSON::JWT.decode(jws[:id_token], pub_key)['nonce']
expect(first_nonce).not_to eq second_nonce
end
it 'sets the "sub" claim' do
expect(decoded_jwt.sub).to eq ''
expect(decoded_jwt['sub']).to eq user.lti_context_id
end
end
@ -314,7 +341,7 @@ describe Lti::Messages::JwtMessage do
let(:account_jwt) do
jws = account_jwt_message.generate_post_payload
JSON::JWT.decode(jws[:id_token], :skip_verification)
JSON::JWT.decode(jws[:id_token], pub_key)
end
it 'adds the canvas account id' do

View File

@ -16,8 +16,10 @@
# with this program. If not, see <http://www.gnu.org/licenses/>.
require File.expand_path(File.dirname(__FILE__) + '/../../../spec_helper.rb')
require File.expand_path(File.dirname(__FILE__) + '/../../../lti_1_3_spec_helper')
describe Lti::Messages::ResourceLinkRequest do
include_context 'lti_1_3_spec_helper'
let(:return_url) { 'http://www.platform.com/return_url' }
let(:opts) { { resource_type: 'course_navigation' } }

View File

@ -20,14 +20,14 @@ require File.expand_path(File.dirname(__FILE__) + '/../../spec_helper')
describe Lti::RSAKeyPair do
describe "initialize" do
it 'generates a public key of default size 256' do
it 'generates a public key of default size 2048' do
keys = Lti::RSAKeyPair.new
expect(/\d+/.match(keys.public_key.to_text())[0]).to eq "256"
expect(/\d+/.match(keys.public_key.to_text())[0]).to eq "2048"
end
it 'generates a private key of default size 256' do
it 'generates a private key of default size 2048' do
keys = Lti::RSAKeyPair.new
expect(/\d+/.match(keys.private_key.to_text())[0]).to eq "256"
expect(/\d+/.match(keys.private_key.to_text())[0]).to eq "2048"
end
end
end

View File

@ -20,7 +20,6 @@ require File.expand_path(File.dirname(__FILE__) + '/../../spec_helper.rb')
require 'simple_oauth'
describe Lti::Security do
describe '.signed_post_params' do
let(:params) { {custom_a: 1, custom_b:2} }
let(:consumer_key) { 'test' }

View File

@ -0,0 +1,32 @@
#
# 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/>.
require File.expand_path(File.dirname(__FILE__) + '/spec_helper.rb')
RSpec.shared_context "lti_1_3_spec_helper", shared_context: :metadata do
let(:fallback_proxy) do
Canvas::DynamicSettings::FallbackProxy.new({
Lti::KeyStorage::PAST => Lti::RSAKeyPair.new.to_jwk.to_json,
Lti::KeyStorage::PRESENT => Lti::RSAKeyPair.new.to_jwk.to_json,
Lti::KeyStorage::FUTURE => Lti::RSAKeyPair.new.to_jwk.to_json
})
end
before do
allow(Canvas::DynamicSettings).to receive(:kv_proxy).and_return(fallback_proxy)
end
end

View File

@ -16,8 +16,11 @@
# with this program. If not, see <http://www.gnu.org/licenses/>.
require File.expand_path(File.dirname(__FILE__) + '/../../spec_helper.rb')
require File.expand_path(File.dirname(__FILE__) + '/../../lti_1_3_spec_helper')
describe Lti::LtiAdvantageAdapter do
include_context 'lti_1_3_spec_helper'
let(:return_url) { 'http://www.platform.com/return_url' }
let(:user) { @student }
let(:opts) { { resource_type: 'course_navigation' } }