Throttle LTI Advantage endpoints by client_id
closes INTEROP-7669 flag=none Test plan: - Open a rails console and run GuardRail.activate!(:deploy) Rails.cache.redis.keys To see the list of redis keys. - Hit an AGS or NRPS endpoint. Look at the list of keys and check that there is now a key we use throttling redis keys based on the client ID "request_throttling:lti_advantage:123-" where 123 is the developer key ID. - If you test on an MRA install where "shard.database_server_id" is not empty, you will see that that key actually includes the database_server_id. Change-Id: Ied277f948df4885c345c3a2ee9dbfc505feec405 Reviewed-on: https://gerrit.instructure.com/c/canvas-lms/+/307178 Reviewed-by: Xander Moffatt <xmoffatt@instructure.com> QA-Review: Xander Moffatt <xmoffatt@instructure.com> Tested-by: Service Cloud Jenkins <svc.cloudjenkins@instructure.com> Product-Review: Evan Battaglia <ebattaglia@instructure.com>
This commit is contained in:
parent
69ad1e0384
commit
1bebaaaece
|
@ -17,70 +17,17 @@
|
|||
# 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/>.
|
||||
|
||||
# NOTE: All routes for controllers which include this module are expected to
|
||||
# start witih /api/lti/, and will bucket throttling based on LTI Advantage
|
||||
# client_id. See RequestThrottle#lti_advantage_client_id_and_cluster.
|
||||
# You may need to adjust Lti::IMS::Concerns::LtiServices.lti_advantage_route? if:
|
||||
# * You include this concern but don't want to bucket by client_id
|
||||
# * You include this concern but have routes which don't start with /api/lti/
|
||||
# * You want to bucket by client_id but don't include this concern
|
||||
module Lti::IMS::Concerns
|
||||
module LtiServices
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
UNIVERSAL_GRANT_HOST = Canvas::Security.config["lti_grant_host"] ||
|
||||
"canvas.instructure.com"
|
||||
|
||||
class AccessToken
|
||||
def initialize(raw_jwt_str)
|
||||
@raw_jwt_str = raw_jwt_str
|
||||
end
|
||||
|
||||
def validate!(expected_audience)
|
||||
validate_claims!(expected_audience)
|
||||
self
|
||||
rescue Canvas::Security::InvalidToken => e
|
||||
case e.cause
|
||||
when JSON::JWT::InvalidFormat
|
||||
raise Lti::IMS::AdvantageErrors::MalformedAccessToken, e
|
||||
when JSON::JWS::UnexpectedAlgorithm
|
||||
raise Lti::IMS::AdvantageErrors::InvalidAccessTokenSignatureType, e
|
||||
when JSON::JWS::VerificationFailed
|
||||
raise Lti::IMS::AdvantageErrors::InvalidAccessTokenSignature, e
|
||||
else
|
||||
raise Lti::IMS::AdvantageErrors::InvalidAccessToken.new(e, api_message: "Access token invalid - signature likely incorrect")
|
||||
end
|
||||
rescue JSON::JWT::Exception => e
|
||||
raise Lti::IMS::AdvantageErrors::InvalidAccessToken, e
|
||||
rescue Canvas::Security::TokenExpired => e
|
||||
raise Lti::IMS::AdvantageErrors::InvalidAccessTokenClaims.new(e, api_message: "Access token expired")
|
||||
rescue Lti::IMS::AdvantageErrors::AdvantageServiceError
|
||||
raise
|
||||
rescue => e
|
||||
raise Lti::IMS::AdvantageErrors::AdvantageServiceError, e
|
||||
end
|
||||
|
||||
def validate_claims!(expected_audience)
|
||||
validator = Canvas::Security::JwtValidator.new(
|
||||
jwt: decoded_jwt,
|
||||
expected_aud: expected_audience,
|
||||
require_iss: true,
|
||||
skip_jti_check: true,
|
||||
max_iat_age: Setting.get("oauth2_jwt_iat_ago_in_seconds", 60.minutes.to_s).to_i.seconds
|
||||
)
|
||||
|
||||
# In this case we know the error message can just be safely shunted into the API response (in other cases
|
||||
# we're more wary about leaking impl details)
|
||||
unless validator.valid?
|
||||
raise Lti::IMS::AdvantageErrors::InvalidAccessTokenClaims.new(
|
||||
nil,
|
||||
api_message: "Invalid access token field/s: #{validator.error_message}"
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
def claim(name)
|
||||
decoded_jwt[name]
|
||||
end
|
||||
|
||||
def decoded_jwt
|
||||
@_decoded_jwt = Canvas::Security.decode_jwt(@raw_jwt_str)
|
||||
end
|
||||
end
|
||||
|
||||
# factories for array matchers typically returned by #scopes_matcher
|
||||
class_methods do
|
||||
def all_of(*items)
|
||||
|
@ -110,15 +57,11 @@ module Lti::IMS::Concerns
|
|||
)
|
||||
|
||||
def verify_access_token
|
||||
if access_token.blank?
|
||||
if (e = Lti::IMS::AdvantageAccessTokenRequestHelper.token_error(request))
|
||||
handled_error(e)
|
||||
render_error(e.api_message, e.status_code)
|
||||
elsif !access_token
|
||||
render_error("Missing access token", :unauthorized)
|
||||
else
|
||||
begin
|
||||
access_token.validate!(expected_access_token_audience)
|
||||
rescue Lti::IMS::AdvantageErrors::AdvantageClientError => e # otherwise it's a system error, so we want normal error trapping and rendering to kick in
|
||||
handled_error(e)
|
||||
render_error(e.api_message, e.status_code)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
|
@ -133,16 +76,7 @@ module Lti::IMS::Concerns
|
|||
end
|
||||
|
||||
def access_token
|
||||
@_access_token ||= begin
|
||||
raw_jwt_str = AuthenticationMethods.access_token(request)
|
||||
AccessToken.new(raw_jwt_str) if raw_jwt_str.present?
|
||||
end
|
||||
end
|
||||
|
||||
def expected_access_token_audience
|
||||
[request.host_with_port, UNIVERSAL_GRANT_HOST].map do |h|
|
||||
Rails.application.routes.url_helpers.oauth2_token_url(host: h, protocol: request.protocol)
|
||||
end
|
||||
@_access_token ||= Lti::IMS::AdvantageAccessTokenRequestHelper.token(request)
|
||||
end
|
||||
|
||||
def access_token_scopes
|
||||
|
@ -159,7 +93,7 @@ module Lti::IMS::Concerns
|
|||
|
||||
def developer_key
|
||||
@_developer_key ||= access_token && begin
|
||||
DeveloperKey.find_cached(access_token.claim("sub"))
|
||||
DeveloperKey.find_cached(access_token.client_id)
|
||||
rescue ActiveRecord::RecordNotFound
|
||||
nil
|
||||
end
|
||||
|
|
|
@ -151,6 +151,7 @@ class RequestThrottle
|
|||
# object won't be caught.
|
||||
def client_identifiers(request)
|
||||
request.env["canvas.request_throttle.user_id"] ||= [
|
||||
tag_identifier("lti_advantage", lti_advantage_client_id_and_cluster(request)),
|
||||
(token_string = AuthenticationMethods.access_token(request, :GET).presence) && "token:#{AccessToken.hashed_token(token_string)}",
|
||||
tag_identifier("user", AuthenticationMethods.user_id(request).presence),
|
||||
tag_identifier("session", session_id(request).presence),
|
||||
|
@ -159,6 +160,20 @@ class RequestThrottle
|
|||
].compact
|
||||
end
|
||||
|
||||
# Bucket based on LTI Advantage client_id. Routes are identified by a combination of path
|
||||
# and whether the controller uses the LtiServices concern -- see lti_advantage_route? method.
|
||||
def lti_advantage_client_id_and_cluster(request)
|
||||
return unless Lti::IMS::AdvantageAccessTokenRequestHelper.lti_advantage_route?(request)
|
||||
|
||||
client_id = Lti::IMS::AdvantageAccessTokenRequestHelper.token(request)&.client_id
|
||||
return unless client_id
|
||||
|
||||
cluster_id = request.env["canvas.domain_root_account"]&.shard&.database_server_id
|
||||
"#{client_id}-#{cluster_id}"
|
||||
rescue Lti::IMS::AdvantageErrors::AdvantageClientError
|
||||
nil
|
||||
end
|
||||
|
||||
def tool_id(request)
|
||||
return unless request.request_method_symbol == :post && request.fullpath =~ %r{/api/lti/v1/tools/([^/]+)/(?:ext_)?grade_passback}
|
||||
|
||||
|
|
|
@ -0,0 +1,86 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
#
|
||||
# Copyright (C) 2013 - 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/>.
|
||||
#
|
||||
|
||||
module Lti
|
||||
module IMS
|
||||
# Used to parse/validate JWT Tokens for LTI Advantage endpoints that use
|
||||
# client_credentials flow such as NRPS and AGS
|
||||
class AdvantageAccessToken
|
||||
def initialize(raw_jwt_str)
|
||||
@raw_jwt_str = raw_jwt_str
|
||||
end
|
||||
|
||||
def validate!(expected_audience)
|
||||
validate_claims!(expected_audience)
|
||||
self
|
||||
rescue Canvas::Security::InvalidToken => e
|
||||
case e.cause
|
||||
when JSON::JWT::InvalidFormat
|
||||
raise AdvantageErrors::MalformedAccessToken, e
|
||||
when JSON::JWS::UnexpectedAlgorithm
|
||||
raise AdvantageErrors::InvalidAccessTokenSignatureType, e
|
||||
when JSON::JWS::VerificationFailed
|
||||
raise AdvantageErrors::InvalidAccessTokenSignature, e
|
||||
else
|
||||
raise AdvantageErrors::InvalidAccessToken.new(e, api_message: "Access token invalid - signature likely incorrect")
|
||||
end
|
||||
rescue JSON::JWT::Exception => e
|
||||
raise AdvantageErrors::InvalidAccessToken, e
|
||||
rescue Canvas::Security::TokenExpired => e
|
||||
raise AdvantageErrors::InvalidAccessTokenClaims.new(e, api_message: "Access token expired")
|
||||
rescue AdvantageErrors::AdvantageServiceError
|
||||
raise
|
||||
rescue => e
|
||||
raise AdvantageErrors::AdvantageServiceError, e
|
||||
end
|
||||
|
||||
def validate_claims!(expected_audience)
|
||||
validator = Canvas::Security::JwtValidator.new(
|
||||
jwt: decoded_jwt,
|
||||
expected_aud: expected_audience,
|
||||
require_iss: true,
|
||||
skip_jti_check: true,
|
||||
max_iat_age: Setting.get("oauth2_jwt_iat_ago_in_seconds", 60.minutes.to_s).to_i.seconds
|
||||
)
|
||||
|
||||
# In this case we know the error message can just be safely shunted into the API response (in other cases
|
||||
# we're more wary about leaking impl details)
|
||||
unless validator.valid?
|
||||
raise AdvantageErrors::InvalidAccessTokenClaims.new(
|
||||
nil,
|
||||
api_message: "Invalid access token field/s: #{validator.error_message}"
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
def claim(name)
|
||||
decoded_jwt[name]
|
||||
end
|
||||
|
||||
def decoded_jwt
|
||||
@_decoded_jwt = Canvas::Security.decode_jwt(@raw_jwt_str)
|
||||
end
|
||||
|
||||
def client_id
|
||||
claim("sub")
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
|
@ -0,0 +1,94 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
#
|
||||
# Copyright (C) 2013 - 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/>.
|
||||
#
|
||||
|
||||
# Parses/validates an LTI Advantage Access Token (used for LTI Advantage
|
||||
# endpoints) and caches the result (token, error, or neither) in the request's
|
||||
# `env`. The token needs to be used in multiple places in the request cycle, so
|
||||
# this way we can parse and validate it once for all uses.
|
||||
module Lti
|
||||
module IMS
|
||||
module AdvantageAccessTokenRequestHelper
|
||||
module_function
|
||||
|
||||
REQUEST_ENV_KEY = "canvas.lti_advantage_token"
|
||||
|
||||
UNIVERSAL_GRANT_HOST = Canvas::Security.config["lti_grant_host"] ||
|
||||
"canvas.instructure.com"
|
||||
|
||||
# Will only return a token if there is a valid one
|
||||
def token(request)
|
||||
token_info(request)[:token]
|
||||
end
|
||||
|
||||
def token_error(request)
|
||||
token_info(request)[:error]
|
||||
end
|
||||
|
||||
def token_info(request)
|
||||
request.env[REQUEST_ENV_KEY] ||=
|
||||
if (raw_jwt_str = AuthenticationMethods.access_token(request))
|
||||
begin
|
||||
token = AdvantageAccessToken.new(raw_jwt_str)
|
||||
token.validate!(expected_audience(request))
|
||||
{ token: token }
|
||||
rescue Lti::IMS::AdvantageErrors::AdvantageClientError => e
|
||||
# otherwise it's a system error, so we want normal error trapping and rendering to kick in
|
||||
{ error: e }
|
||||
end
|
||||
else
|
||||
{}
|
||||
end
|
||||
end
|
||||
|
||||
def expected_audience(request)
|
||||
[request.host_with_port, UNIVERSAL_GRANT_HOST].map do |h|
|
||||
Rails.application.routes.url_helpers.oauth2_token_url(host: h, protocol: request.protocol)
|
||||
end
|
||||
end
|
||||
|
||||
# Checks that the route uses a controller that includes LtiServices
|
||||
def lti_advantage_route?(request)
|
||||
# Currently, all LTI Advantage routes start with /api/lti/. So, we can
|
||||
# check the path first so we don't waste time with the relatively-slow
|
||||
# controller check for the 99+% of the traffic that isn't an LTI endpoint
|
||||
return false unless request&.fullpath&.start_with?("/api/lti/")
|
||||
|
||||
begin
|
||||
controller_name = Rails.application.routes.recognize_path(
|
||||
request.env["PATH_INFO"],
|
||||
method: request.env["REQUEST_METHOD"]
|
||||
)&.dig(:controller)
|
||||
rescue ActionController::RoutingError
|
||||
return false
|
||||
end
|
||||
|
||||
return false unless controller_name
|
||||
|
||||
begin
|
||||
controller_class = "#{controller_name}_controller".classify.constantize
|
||||
rescue NameError
|
||||
return false
|
||||
end
|
||||
|
||||
controller_class.include?(Lti::IMS::Concerns::LtiServices)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
|
@ -19,59 +19,12 @@
|
|||
#
|
||||
|
||||
require "lti_1_3_tool_configuration_spec_helper"
|
||||
require "lib/lti/ims/advantage_access_token_shared_context"
|
||||
|
||||
shared_context "advantage services context" do
|
||||
include_context "lti_1_3_tool_configuration_spec_helper"
|
||||
include_context "advantage access token context"
|
||||
|
||||
let_once(:root_account) do
|
||||
Account.default
|
||||
end
|
||||
let_once(:developer_key) do
|
||||
dk = DeveloperKey.create!(account: root_account)
|
||||
dk.developer_key_account_bindings.first.update! workflow_state: "on"
|
||||
dk
|
||||
end
|
||||
let(:access_token_scopes) do
|
||||
%w[
|
||||
https://purl.imsglobal.org/spec/lti-ags/scope/lineitem
|
||||
https://purl.imsglobal.org/spec/lti-ags/scope/result.readonly
|
||||
https://purl.imsglobal.org/spec/lti-nrps/scope/contextmembership.readonly
|
||||
https://canvas.instructure.com/lti/public_jwk/scope/update
|
||||
https://canvas.instructure.com/lti/data_services/scope/create
|
||||
https://canvas.instructure.com/lti/data_services/scope/show
|
||||
https://canvas.instructure.com/lti/data_services/scope/update
|
||||
https://canvas.instructure.com/lti/data_services/scope/list
|
||||
https://canvas.instructure.com/lti/data_services/scope/destroy
|
||||
https://canvas.instructure.com/lti/data_services/scope/list_event_types
|
||||
https://canvas.instructure.com/lti/account_lookup/scope/show
|
||||
https://canvas.instructure.com/lti/feature_flags/scope/show
|
||||
https://canvas.instructure.com/lti/account_external_tools/scope/create
|
||||
https://canvas.instructure.com/lti/account_external_tools/scope/update
|
||||
https://canvas.instructure.com/lti/account_external_tools/scope/list
|
||||
https://canvas.instructure.com/lti/account_external_tools/scope/show
|
||||
https://canvas.instructure.com/lti/account_external_tools/scope/destroy
|
||||
].join(" ")
|
||||
end
|
||||
let(:access_token_signing_key) { Canvas::Security.encryption_key }
|
||||
let(:test_request_host) { "test.host" }
|
||||
let(:access_token_jwt_hash) do
|
||||
timestamp = Time.zone.now.to_i
|
||||
{
|
||||
iss: "https://canvas.instructure.com",
|
||||
sub: developer_key.global_id,
|
||||
aud: "http://#{test_request_host}/login/oauth2/token",
|
||||
iat: timestamp,
|
||||
exp: (timestamp + 1.hour.to_i),
|
||||
nbf: (timestamp - 30),
|
||||
jti: SecureRandom.uuid,
|
||||
scopes: access_token_scopes
|
||||
}
|
||||
end
|
||||
let(:access_token_jwt) do
|
||||
return nil if access_token_jwt_hash.blank?
|
||||
|
||||
JSON::JWT.new(access_token_jwt_hash).sign(access_token_signing_key, :HS256).to_s
|
||||
end
|
||||
let(:tool_context) { root_account }
|
||||
let!(:tool) do
|
||||
ContextExternalTool.create!(
|
||||
|
|
|
@ -0,0 +1,33 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
#
|
||||
# Copyright (C) 2022 - 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 "request throttling", type: :request do
|
||||
describe "usage of the middleware in the Canvas middleware stack" do
|
||||
it "comes below the middleware which populates the canvas domain root account" do
|
||||
acct = nil
|
||||
expect_any_instance_of(RequestThrottle).to receive(:client_identifiers) do |_subject, req|
|
||||
# for some reason "expect"s in here don't fail the spec -- presumably the error is
|
||||
# rescued above in the stack. So, assign acct and test it later
|
||||
acct = req.env["canvas.domain_root_account"]
|
||||
end
|
||||
get "/"
|
||||
expect(acct).to be_a(Account)
|
||||
end
|
||||
end
|
||||
end
|
|
@ -18,7 +18,7 @@
|
|||
# with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
|
||||
describe "RequestThrottle" do
|
||||
describe RequestThrottle do
|
||||
let(:base_req) { { "QUERY_STRING" => "", "PATH_INFO" => "/", "REQUEST_METHOD" => "GET" } }
|
||||
let(:request_user_1) { base_req.merge({ "REMOTE_ADDR" => "1.2.3.4", "rack.session" => { user_id: 1 } }) }
|
||||
let(:request_user_2) { base_req.merge({ "REMOTE_ADDR" => "4.3.2.1", "rack.session" => { user_id: 2 } }) }
|
||||
|
@ -94,6 +94,23 @@ describe "RequestThrottle" do
|
|||
expect(ContextExternalTool).not_to receive(:find_by)
|
||||
expect(throttler.client_identifier(req(request_grade_passback))).to eq nil
|
||||
end
|
||||
|
||||
it "uses client_id+cluster for LTI Advantage endpoints" do
|
||||
request = req(
|
||||
base_req.merge(
|
||||
"canvas.domain_root_account" => Account.default,
|
||||
"PATH_INFO" => "/api/lti/courses/2/line_items/327/results"
|
||||
)
|
||||
)
|
||||
|
||||
mock_token = instance_double(Lti::IMS::AdvantageAccessToken, client_id: "10000000000007")
|
||||
expect(Lti::IMS::AdvantageAccessTokenRequestHelper).to \
|
||||
receive(:token).with(request).and_return(mock_token)
|
||||
expect(Account.default.shard).to receive(:database_server_id).and_return "cluster123"
|
||||
|
||||
result = throttler.client_identifier(request)
|
||||
expect(result).to eq "lti_advantage:10000000000007-cluster123"
|
||||
end
|
||||
end
|
||||
|
||||
describe "#call" do
|
||||
|
|
|
@ -0,0 +1,150 @@
|
|||
# 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/>.
|
||||
|
||||
require_dependency "lti/ims/advantage_access_token_request_helper"
|
||||
|
||||
describe Lti::IMS::AdvantageAccessTokenRequestHelper do
|
||||
let(:token) { double(Lti::IMS::AdvantageAccessToken) }
|
||||
let(:request) { ActionDispatch::Request.new(Rack::MockRequest.env_for("http://test.host/")) }
|
||||
|
||||
let(:subject_token) { described_class.token(request) }
|
||||
|
||||
context "when the request has a token" do
|
||||
before do
|
||||
allow(AuthenticationMethods).to receive(:access_token).and_return("fakejwt")
|
||||
allow(Lti::IMS::AdvantageAccessToken).to receive(:new).with("fakejwt").and_return(token)
|
||||
end
|
||||
|
||||
context "when the request has a good token" do
|
||||
before { allow(token).to receive(:validate!).and_return(token) }
|
||||
|
||||
specify ".token() returns the token" do
|
||||
expect(described_class.token(request)).to eq(token)
|
||||
end
|
||||
|
||||
specify ".token_error() returns nil" do
|
||||
expect(described_class.token_error(request)).to eq(nil)
|
||||
end
|
||||
|
||||
it "uses the oauth url for request.host_with_port, and the default grant host, as possible audience values" do
|
||||
expected_auds = [
|
||||
"http://test.host/login/oauth2/token",
|
||||
"http://canvas.instructure.com/login/oauth2/token"
|
||||
]
|
||||
described_class.token(request)
|
||||
expect(token).to have_received(:validate!).with(expected_auds)
|
||||
end
|
||||
end
|
||||
|
||||
context "when the request has a bad token" do
|
||||
let(:err) { Lti::IMS::AdvantageErrors::AdvantageClientError.new("foo") }
|
||||
|
||||
before do
|
||||
allow(token).to receive(:validate!).and_return(token).and_raise(err)
|
||||
end
|
||||
|
||||
specify ".token() returns nil" do
|
||||
expect(described_class.token(request)).to eq(nil)
|
||||
end
|
||||
|
||||
specify ".token_error() returns an error" do
|
||||
expect(described_class.token_error(request)).to eq(err)
|
||||
end
|
||||
|
||||
it "caches the result so it doesn't parse the token twice" do
|
||||
2.times do
|
||||
described_class.token(request)
|
||||
described_class.token_error(request)
|
||||
end
|
||||
expect(Lti::IMS::AdvantageAccessToken).to have_received(:new).exactly(:once)
|
||||
expect(token).to have_received(:validate!).exactly(:once)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context "when the request has no token" do
|
||||
let(:err) { Lti::IMS::AdvantageErrors::AdvantageServiceError.new("foo") }
|
||||
|
||||
before { allow(AuthenticationMethods).to receive(:access_token).and_return(nil) }
|
||||
|
||||
specify ".token() returns nil" do
|
||||
expect(described_class.token(request)).to eq(nil)
|
||||
end
|
||||
|
||||
specify ".token_error() returns nil" do
|
||||
expect(described_class.token_error(request)).to eq(nil)
|
||||
end
|
||||
end
|
||||
|
||||
describe ".lti_advantage_route?" do
|
||||
def result_for_path(path)
|
||||
req = ActionDispatch::Request.new(Rack::MockRequest.env_for("http://test.host/#{path}"))
|
||||
described_class.lti_advantage_route?(req)
|
||||
end
|
||||
|
||||
it "returns true if the route's controller includes the LtiServices concern" do
|
||||
expect(result_for_path("api/lti/courses/123/line_items")).to eq(true)
|
||||
# Account lookup controller:
|
||||
expect(result_for_path("api/lti/accounts/123")).to eq(true)
|
||||
end
|
||||
|
||||
it "returns false if given a route for a controller that doesn't include LtiServices" do
|
||||
expect(result_for_path("api/courses/123")).to eq(false)
|
||||
expect(result_for_path("api/lti/assignments/1/files/2/originality_report")).to eq(false)
|
||||
expect(result_for_path("api/lti/security/jwks")).to eq(false)
|
||||
end
|
||||
|
||||
it "returns false if given a bad route" do
|
||||
expect(result_for_path("blablablanonsense-route-doesnexist")).to eq(false)
|
||||
expect(result_for_path("api/lti/blablabla-wombat123")).to eq(false)
|
||||
end
|
||||
|
||||
it "returns false if the route references a non-existent controller" do
|
||||
# probably wouldn't happen, but it does in the specs, and if it happens I
|
||||
# don't want to blow up here in this method
|
||||
expect(Rails.application.routes).to \
|
||||
receive(:recognize_path).and_return(controller: "oops_this_controller_doesnt/really_exist")
|
||||
expect(result_for_path("api/lti/security/jwks")).to eq(false)
|
||||
end
|
||||
|
||||
specify "there are no routes for controllers which include the LtiServices concern that don't start with /api/lti" do
|
||||
# This is an assumption that underlies an optimization in
|
||||
# lti_advantage_route? -- we don't parse the route if the request path
|
||||
# doesn't start with /api/lti. So all routes that use LtiServices need to
|
||||
# start with /api/lti
|
||||
Rails.application.routes.routes.each do |r|
|
||||
path_spec = r.path.spec.to_s
|
||||
next unless r.defaults[:controller]
|
||||
|
||||
begin
|
||||
controller = "#{r.defaults[:controller]}_controller".classify.constantize
|
||||
rescue NameError
|
||||
next
|
||||
end
|
||||
|
||||
next unless controller.include?(Lti::IMS::Concerns::LtiServices)
|
||||
|
||||
err_msg = "path #{path_spec.inspect} (controller #{controller.name}) uses " \
|
||||
"LtiServices but path does not start with /api/lti -- optimization in " \
|
||||
"`lti_advantage_route?` will fail"
|
||||
expect(path_spec).to start_with("/api/lti/"), err_msg
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
|
@ -0,0 +1,71 @@
|
|||
# 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/>.
|
||||
|
||||
shared_context "advantage access token context" do
|
||||
let_once(:root_account) do
|
||||
Account.default
|
||||
end
|
||||
let_once(:developer_key) do
|
||||
dk = DeveloperKey.create!(account: root_account)
|
||||
dk.developer_key_account_bindings.first.update! workflow_state: "on"
|
||||
dk
|
||||
end
|
||||
let(:access_token_scopes) do
|
||||
%w[
|
||||
https://purl.imsglobal.org/spec/lti-ags/scope/lineitem
|
||||
https://purl.imsglobal.org/spec/lti-ags/scope/result.readonly
|
||||
https://purl.imsglobal.org/spec/lti-nrps/scope/contextmembership.readonly
|
||||
https://canvas.instructure.com/lti/public_jwk/scope/update
|
||||
https://canvas.instructure.com/lti/data_services/scope/create
|
||||
https://canvas.instructure.com/lti/data_services/scope/show
|
||||
https://canvas.instructure.com/lti/data_services/scope/update
|
||||
https://canvas.instructure.com/lti/data_services/scope/list
|
||||
https://canvas.instructure.com/lti/data_services/scope/destroy
|
||||
https://canvas.instructure.com/lti/data_services/scope/list_event_types
|
||||
https://canvas.instructure.com/lti/account_lookup/scope/show
|
||||
https://canvas.instructure.com/lti/feature_flags/scope/show
|
||||
https://canvas.instructure.com/lti/account_external_tools/scope/create
|
||||
https://canvas.instructure.com/lti/account_external_tools/scope/update
|
||||
https://canvas.instructure.com/lti/account_external_tools/scope/list
|
||||
https://canvas.instructure.com/lti/account_external_tools/scope/show
|
||||
https://canvas.instructure.com/lti/account_external_tools/scope/destroy
|
||||
].join(" ")
|
||||
end
|
||||
let(:access_token_signing_key) { Canvas::Security.encryption_key }
|
||||
let(:test_request_host) { "test.host" }
|
||||
let(:access_token_aud) { "http://#{test_request_host}/login/oauth2/token" }
|
||||
let(:access_token_jwt_hash) do
|
||||
timestamp = Time.zone.now.to_i
|
||||
{
|
||||
iss: "https://canvas.instructure.com",
|
||||
sub: developer_key.global_id,
|
||||
aud: access_token_aud,
|
||||
iat: timestamp,
|
||||
exp: (timestamp + 1.hour.to_i),
|
||||
nbf: (timestamp - 30),
|
||||
jti: SecureRandom.uuid,
|
||||
scopes: access_token_scopes
|
||||
}
|
||||
end
|
||||
let(:access_token_jwt) do
|
||||
return nil if access_token_jwt_hash.blank?
|
||||
|
||||
JSON::JWT.new(access_token_jwt_hash).sign(access_token_signing_key, :HS256).to_s
|
||||
end
|
||||
end
|
|
@ -0,0 +1,62 @@
|
|||
# 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/>.
|
||||
|
||||
require_relative "advantage_access_token_shared_context"
|
||||
|
||||
describe Lti::IMS::AdvantageAccessToken do
|
||||
include_context "advantage access token context"
|
||||
|
||||
describe "#validate!" do
|
||||
let(:token) { described_class.new(access_token_jwt) }
|
||||
|
||||
it "returns 'self' when the token is valid" do
|
||||
expect(token.validate!(access_token_aud)).to eq(token)
|
||||
end
|
||||
|
||||
it "tries all aud values passed in" do
|
||||
acceptable_auds = ["http://example.com/", access_token_aud, "http://example2.com/"]
|
||||
expect(token.validate!(acceptable_auds)).to eq(token)
|
||||
end
|
||||
|
||||
it "raises a specific type of AdvantageClientError if the aud is invalid" do
|
||||
acceptable_auds = ["http://example.com/", "http://example2.com/"]
|
||||
expect { token.validate!(acceptable_auds) }.to \
|
||||
raise_error(Lti::IMS::AdvantageErrors::InvalidAccessTokenClaims, /\Waud\W.*invalid/)
|
||||
expect(Lti::IMS::AdvantageErrors::InvalidAccessTokenClaims).to \
|
||||
be < Lti::IMS::AdvantageErrors::AdvantageClientError
|
||||
end
|
||||
|
||||
it "raises a specific type of AdvantageClientError when the token is malformed" do
|
||||
token = described_class.new("garbage")
|
||||
expect { token.validate!(access_token_aud) }.to \
|
||||
raise_error(Lti::IMS::AdvantageErrors::MalformedAccessToken)
|
||||
expect(Lti::IMS::AdvantageErrors::MalformedAccessToken).to \
|
||||
be < Lti::IMS::AdvantageErrors::AdvantageClientError
|
||||
end
|
||||
|
||||
it "raises a specific type of AdvantageClientError when decoding the JWT raises a JSON::JWT::Exception" do
|
||||
expect(Canvas::Security::JwtValidator).to \
|
||||
receive(:new).and_raise(JSON::JWT::Exception)
|
||||
expect { token.validate!(access_token_aud) }.to \
|
||||
raise_error(Lti::IMS::AdvantageErrors::InvalidAccessToken)
|
||||
expect(Lti::IMS::AdvantageErrors::InvalidAccessToken).to \
|
||||
be < Lti::IMS::AdvantageErrors::AdvantageClientError
|
||||
end
|
||||
end
|
||||
end
|
Loading…
Reference in New Issue