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:
Evan Battaglia 2022-12-12 07:37:06 -07:00
parent 69ad1e0384
commit 1bebaaaece
10 changed files with 544 additions and 129 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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