tool proxy custom tcp and tp authorization code

fixes PLAT-2323

test plan:

-attempt to register a TP using the authorization code workflow
-it should let you create a tool proxy

-attempt to register a TP using the client credentials JWT workflow
-it should give you a 401

-attempt to register using a custom Tool Consumer Profile
-it should let you

there is a problem with using developer keys to register tools
since we aren't using a onetime token it no longer requires a admin
to kick off the process to install the tool.  We will need to do
something to address this, and ensure they have permission to install
in this context

Change-Id: I95ed14a8f818f02dab8340dfde3cc6327c06c793
Reviewed-on: https://gerrit.instructure.com/103267
QA-Review: August Thornton <august@instructure.com>
Reviewed-by: Weston Dransfield <wdransfield@instructure.com>
Reviewed-by: Andrew Butterfield <abutterfield@instructure.com>
Product-Review: Nathan Mills <nathanm@instructure.com>
Tested-by: Jenkins
This commit is contained in:
Nathan Mills 2017-02-24 18:37:34 -07:00
parent 7381e14ab2
commit 03aa58570e
10 changed files with 221 additions and 78 deletions

View File

@ -67,4 +67,8 @@ module Lti::Ims::AccessTokenHelper
raise 'the method #lti2_service_name must be defined in the class'
end
def render_unauthorized
render json: {error: 'unauthorized'}, status: :unauthorized
end
end

View File

@ -42,6 +42,8 @@ module Lti
#
class AuthorizationController < ApplicationController
skip_before_action :load_user, :require_user
SERVICE_DEFINITIONS = [
{
id: 'vnd.Canvas.authorization',
@ -53,12 +55,15 @@ module Lti
class InvalidGrant < RuntimeError; end
JWT_GRANT_TYPE = 'urn:ietf:params:oauth:grant-type:jwt-bearer'.freeze
AUTHORIZATION_CODE_GRANT_TYPE = 'authorization_code'.freeze
GRANT_TYPES = [JWT_GRANT_TYPE, AUTHORIZATION_CODE_GRANT_TYPE].freeze
rescue_from JSON::JWS::VerificationFailed,
JSON::JWT::InvalidFormat,
JSON::JWS::UnexpectedAlgorithm,
Lti::Oauth2::AuthorizationValidator::InvalidAuthJwt,
Lti::Oauth2::AuthorizationValidator::SecretNotFound,
Lti::Oauth2::AuthorizationValidator::MissingAuthorizationCode,
InvalidGrant do
render json: {error: 'invalid_grant'}, status: :bad_request
end
@ -67,7 +72,14 @@ module Lti
# Returns an access token that can be used to access other LTI services
#
# @argument grant_type [Required, String]
# should contain the exact value of: "urn:ietf:params:oauth:grant-type:jwt-bearer"
# When using registration provided credentials it should contain the exact value of:
# "urn:ietf:params:oauth:grant-type:jwt-bearer" once a tool proxy is created
# When using developer credentials it should have the value of: "authorization_code" and pass
# the optional argument `code` defined below
#
# @argument code [optional, String]
# Only used in conjunction with a grant type of "authorization_code". Should contain the "reg_key" from the
# registration message
#
# @argument assertion [Required, AuthorizationJWT]
# The AuthorizationJWT here should be the JWT in a string format
@ -79,12 +91,17 @@ module Lti
#
# @returns [AccessToken]
def authorize
raise InvalidGrant if params[:grant_type] != JWT_GRANT_TYPE
raise InvalidGrant unless GRANT_TYPES.include?(params[:grant_type])
raise InvalidGrant if params[:assertion].blank?
jwt_validator = Lti::Oauth2::AuthorizationValidator.new(jwt: params[:assertion], authorization_url: lti_oauth2_authorize_url)
code = params[:code]
jwt_validator = Lti::Oauth2::AuthorizationValidator.new(
jwt: params[:assertion],
authorization_url: lti_oauth2_authorize_url,
code: code
)
jwt_validator.validate!
render json: {
access_token: Lti::Oauth2::AccessToken.create_jwt(aud: request.host, sub: jwt_validator.sub).to_s,
access_token: Lti::Oauth2::AccessToken.create_jwt(aud: request.host, sub: jwt_validator.sub, reg_key: code).to_s,
token_type: 'bearer',
expires_in: Setting.get('lti.oauth2.access_token.expiration', 1.hour.to_s)
}

View File

@ -56,14 +56,19 @@ module Lti
def create
if oauth2_request?
dev_key = DeveloperKey.find_cached(access_token.sub)
render_new_tool_proxy(context, SecureRandom.uuid, dev_key) and return if authorized_lti2_tool
begin
validate_access_token!
reg_key = access_token.reg_key
reg_secret = RegistrationRequestService.retrieve_registration_password(context, reg_key) if reg_key
render_new_tool_proxy(context, reg_key, dev_key) and return if reg_secret.present?
rescue Lti::Oauth2::InvalidTokenError
render_unauthorized and return
end
else
tool_proxy_guid = oauth_consumer_key
secret = RegistrationRequestService.retrieve_registration_password(context, oauth_consumer_key)
render_new_tool_proxy(context, SecureRandom.uuid) and return if secret.present? && oauth_authenticated_request?(secret)
render_new_tool_proxy(context, oauth_consumer_key) and return if secret.present? && oauth_authenticated_request?(secret)
end
render json: {error: 'unauthorized'}, status: :unauthorized
render_unauthorized
end
def re_reg

View File

@ -37,11 +37,13 @@ module Lti
end
def process_tool_proxy_json(json:, context:, guid:, tool_proxy_to_update: nil, tc_half_shared_secret: nil, developer_key: nil, tcp_uuid: Lti::ToolConsumerProfile::DEFAULT_TCP_UUID)
def process_tool_proxy_json(json:, context:, guid:, tool_proxy_to_update: nil, tc_half_shared_secret: nil, developer_key: nil)
@tc_half_secret = tc_half_shared_secret
tp = IMS::LTI::Models::ToolProxy.new.from_json(json)
tp.tool_proxy_guid = guid
tcp_uuid = tp.tool_consumer_profile&.match(/tool_consumer_profile\/([a-fA-f0-9\-]+)/)&.captures&.first
tcp_uuid ||= developer_key&.tool_consumer_profile&.uuid
tcp_uuid ||= Lti::ToolConsumerProfile::DEFAULT_TCP_UUID
begin
validate_proxy!(tp, context, developer_key, tcp_uuid)
rescue Lti::ToolProxyService::InvalidToolProxyError

View File

@ -5,10 +5,10 @@ module Lti
ISS = 'Canvas'.freeze
attr_reader :aud, :sub
attr_reader :aud, :sub, :reg_key
def self.create_jwt(aud:, sub:)
new(aud: aud, sub: sub)
def self.create_jwt(aud:, sub:, reg_key: nil)
new(aud: aud, sub: sub, reg_key: reg_key)
end
def self.from_jwt(aud:, jwt:)
@ -20,8 +20,9 @@ module Lti
raise InvalidTokenError, e
end
def initialize(aud:, sub:, jwt: nil)
def initialize(aud:, sub:, jwt: nil, reg_key: nil)
@_jwt = jwt if jwt
@reg_key = reg_key || (jwt && decoded_jwt['reg_key'])
@aud = aud
@sub = sub
end
@ -62,6 +63,7 @@ module Lti
nbf: Setting.get('lti.oauth2.access_token.nbf', 30.seconds).to_i.seconds.ago,
jti: SecureRandom.uuid
}
body[:reg_key] = @reg_key if @reg_key
Canvas::Security.create_jwt(body)
end
end

View File

@ -9,10 +9,13 @@ module Lti
end
class InvalidAuthJwt < StandardError
end
class MissingAuthorizationCode < StandardError
end
def initialize(jwt:, authorization_url:)
def initialize(jwt:, authorization_url:, code: nil)
@raw_jwt = jwt
@authorization_url = authorization_url
@code = code
end
def jwt
@ -52,9 +55,13 @@ module Lti
end
def developer_key
@_developer_key ||= DeveloperKey.find_cached(unverified_jwt[:sub])
rescue ActiveRecord::RecordNotFound
return nil
@_developer_key ||= begin
dev_key = DeveloperKey.find_cached(unverified_jwt[:sub])
raise MissingAuthorizationCode if dev_key && @code.blank?
dev_key
rescue ActiveRecord::RecordNotFound
return nil
end
end
def sub

View File

@ -35,7 +35,6 @@ module Lti
let(:raw_jwt) do
raw_jwt = JSON::JWT.new(
{
iss: tool_proxy.guid,
sub: tool_proxy.guid,
aud: lti_oauth2_authorize_url,
exp: 1.minute.from_now,
@ -43,21 +42,21 @@ module Lti
jti: SecureRandom.uuid
}
)
raw_jwt.kid = tool_proxy.guid
raw_jwt
end
let(:auth_endpoint) { '/api/lti/authorize' }
let(:jwt_string) do
raw_jwt.sign(tool_proxy.shared_secret, :HS256).to_s
end
let(:params) do
{
grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer',
assertion: jwt_string
}
end
describe "POST 'authorize'" do
let(:auth_endpoint) { '/api/lti/authorize' }
let(:assertion) do
raw_jwt.sign(tool_proxy.shared_secret, :HS256).to_s
end
let(:params) do
{
grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer',
assertion: assertion
}
end
it 'responds with 200' do
post auth_endpoint, params
@ -101,6 +100,46 @@ module Lti
expect(response.body).to eq({error: 'invalid_grant'}.to_json)
end
context "developer credentials" do
let(:raw_jwt) do
raw_jwt = JSON::JWT.new(
{
sub: developer_key.global_id,
aud: lti_oauth2_authorize_url,
exp: 1.minute.from_now,
iat: Time.zone.now.to_i,
jti: SecureRandom.uuid,
}
)
raw_jwt
end
let(:jwt_string) do
raw_jwt.sign(developer_key.api_key, :HS256).to_s
end
let(:params) do
{
grant_type: 'authorization_code',
assertion: jwt_string,
code: 'reg_key'
}
end
it "rejects the request if a reg_key isn't provided and grant_type is auth code" do
post auth_endpoint, params.delete(:code)
expect(response.code).to eq '400'
end
it "accepts a developer key with a reg key" do
post auth_endpoint, params
expect(response.code).to eq '200'
end
end
end
end
end

View File

@ -63,7 +63,9 @@ module Lti
describe "POST #create" do
before(:each) do
OAuth::Signature.stubs(:build).returns(mock(verify: true))
mock_oauth_sig = mock('oauth_signature')
mock_oauth_sig.stubs(:verify).returns(true)
OAuth::Signature.stubs(:build).returns(mock_oauth_sig)
OAuth::Helper.stubs(:parse_header).returns({'oauth_consumer_key' => 'key'})
Lti::RegistrationRequestService.stubs(:retrieve_registration_password).returns('password')
end
@ -111,18 +113,73 @@ module Lti
expect(response).to eq 201
expect(JSON.parse(body).keys).to match_array ["@context", "@type", "@id", "tool_proxy_guid", "tc_half_shared_secret"]
end
context "custom tool consumer profile" do
let(:account) {Account.create!}
let(:dev_key) do
dev_key = DeveloperKey.create(api_key: 'test-api-key')
DeveloperKey.stubs(:find_cached).returns(dev_key)
dev_key
end
let!(:tcp) do
dev_key.create_tool_consumer_profile!(
services: Lti::ToolConsumerProfile::RESTRICTED_SERVICES,
capabilities: Lti::ToolConsumerProfile::RESTRICTED_CAPABILITIES,
uuid: SecureRandom.uuid,
developer_key: dev_key
)
end
let(:tcp_url) {polymorphic_url([account, :tool_consumer_profile], tool_consumer_profile_id: tcp.uuid)}
let(:access_token) do
aud = host rescue (@request || request).host
Lti::Oauth2::AccessToken.create_jwt(aud: aud, sub: developer_key.global_id, reg_key: 'reg_key')
end
let(:request_headers) { {Authorization: "Bearer #{access_token}"} }
it 'supports using a specified custom TCP' do
course_with_teacher_logged_in(:active_all => true)
tool_proxy_fixture = File.read(File.join(Rails.root, 'spec', 'fixtures', 'lti', 'tool_proxy.json'))
tp = IMS::LTI::Models::ToolProxy.new.from_json(tool_proxy_fixture)
message = tp.tool_profile.resource_handlers.first.messages.first
tp.tool_consumer_profile = tcp_url
message.enabled_capability = *Lti::ToolConsumerProfile::RESTRICTED_CAPABILITIES
headers = {'CONTENT_TYPE' => 'application/json', 'ACCEPT' => 'application/json'}
headers.merge!(request_headers)
response = post "/api/lti/accounts/#{@course.account.id}/tool_proxy.json", tp.to_json, headers
expect(response).to eq 201
end
end
end
describe "POST #create with JWT access token" do
let(:access_token) do
aud = host rescue (@request || request).host
Lti::Oauth2::AccessToken.create_jwt(aud: aud, sub: developer_key.global_id, reg_key: 'reg_key')
end
let(:request_headers) { {Authorization: "Bearer #{access_token}"} }
it 'accepts valid JWT access tokens' do
course_with_teacher_logged_in(:active_all => true)
Lti::RegistrationRequestService.
stubs(:retrieve_registration_password).with(@course.account, 'reg_key').returns('password')
tool_proxy_fixture = File.read(File.join(Rails.root, 'spec', 'fixtures', 'lti', 'tool_proxy.json'))
json = JSON.parse(tool_proxy_fixture)
json[:format] = 'json'
json[:account_id] = @course.account.id
response = post "/api/lti/accounts/#{@course.account.id}/tool_proxy.json", tool_proxy_fixture, request_headers
expect(response).to eq 201
end
it 'returns a 401 if the reg_key is not valid' do
course_with_teacher_logged_in(:active_all => true)
tool_proxy_fixture = File.read(File.join(Rails.root, 'spec', 'fixtures', 'lti', 'tool_proxy.json'))
json = JSON.parse(tool_proxy_fixture)
json[:format] = 'json'
json[:account_id] = @course.account.id
response = post "/api/lti/accounts/#{@course.account.id}/tool_proxy.json", tool_proxy_fixture, dev_key_request_headers
expect(response).to eq 201
expect(response).to eq 401
end
end
describe "POST #reregistration" do

View File

@ -81,6 +81,11 @@ module Lti
expect(Canvas::Security.decode_jwt(access_token.to_s)['sub']).to eq sub
end
it "includes the reg_key if passed in" do
access_token = Lti::Oauth2::AccessToken.create_jwt(aud: aud, sub: sub, reg_key: 'reg_key')
expect(Canvas::Security.decode_jwt(access_token.to_s)['reg_key']).to eq('reg_key')
end
end
describe ".from_jwt" do

View File

@ -4,18 +4,14 @@ module Lti
module Oauth2
describe AuthorizationValidator do
let(:developer_key) do
developer_key_mock = mock("developer_key")
developer_key_mock.stubs(:active?).returns(true)
developer_key_mock
end
let(:product_family) do
product_family_mock = mock("product_family")
product_family_mock.stubs(:developer_key).returns(developer_key)
product_family_mock.stubs(:developer_key).returns(dev_key)
product_family_mock
end
let(:account) { Account.create! }
let(:tool_proxy) do
tool_proxy_mock = mock("tool_proxy")
tool_proxy_mock.stubs(:guid).returns("3b7f3b02-b481-4f63-a6b0-129dee85abee")
@ -40,7 +36,7 @@ module Lti
)
raw_jwt
end
let(:dev_key){ DeveloperKey.create! }
let(:dev_key) { DeveloperKey.create! }
let(:raw_jwt_dev_key) do
raw_jwt = JSON::JWT.new(
{
@ -54,7 +50,7 @@ module Lti
raw_jwt
end
let(:authValidator) do
let(:auth_validator) do
AuthorizationValidator.new(
jwt: raw_jwt.sign(tool_proxy.shared_secret, :HS256).to_s,
authorization_url: auth_url
@ -69,13 +65,13 @@ module Lti
describe "#jwt" do
it "returns the decoded JWT" do
expect(authValidator.jwt.signature).to eq raw_jwt.sign(tool_proxy.shared_secret, :HS256).signature
expect(auth_validator.jwt.signature).to eq raw_jwt.sign(tool_proxy.shared_secret, :HS256).signature
end
it "raises Lti::Oauth2::AuthorizationValidator::InvalidAuthJwt if any of the assertions are missing" do
raw_jwt.delete 'exp'
expect { authValidator.jwt }.to raise_error Lti::Oauth2::AuthorizationValidator::InvalidAuthJwt,
"the following assertions are missing: exp"
expect { auth_validator.jwt }.to raise_error Lti::Oauth2::AuthorizationValidator::InvalidAuthJwt,
"the following assertions are missing: exp"
end
it 'raises JSON::JWT:InvalidFormat if the JWT format is invalid' do
@ -97,32 +93,32 @@ module Lti
it "raises Lti::Oauth2::AuthorizationValidator::InvalidAuthJwt if the 'exp' is to far in the future" do
raw_jwt['exp'] = 5.minutes.from_now.to_i
Setting.set('lti.oauth2.authorize.max.expiration', 1.minute.to_i)
expect { authValidator.jwt }.to raise_error Lti::Oauth2::AuthorizationValidator::InvalidAuthJwt,
"the 'exp' must not be any further than #{60.seconds} seconds in the future"
expect { auth_validator.jwt }.to raise_error Lti::Oauth2::AuthorizationValidator::InvalidAuthJwt,
"the 'exp' must not be any further than #{60.seconds} seconds in the future"
end
it "raises Lti::Oauth2::AuthorizationValidator::InvalidAuthJwt if the 'exp' is in the past" do
raw_jwt['exp'] = 5.minutes.ago.to_i
expect { authValidator.jwt }.to raise_error Lti::Oauth2::AuthorizationValidator::InvalidAuthJwt, "the JWT has expired"
expect { auth_validator.jwt }.to raise_error Lti::Oauth2::AuthorizationValidator::InvalidAuthJwt, "the JWT has expired"
end
it "raises Lti::Oauth2::AuthorizationValidator::InvalidAuthJwt if the 'iat' to old" do
raw_jwt['iat'] = 10.minutes.ago.to_i
Setting.set('lti.oauth2.authorize.max_iat_age', 5.minutes.to_s)
expect { authValidator.jwt }.to raise_error Lti::Oauth2::AuthorizationValidator::InvalidAuthJwt,
"the 'iat' must be less than #{5.minutes} seconds old"
expect { auth_validator.jwt }.to raise_error Lti::Oauth2::AuthorizationValidator::InvalidAuthJwt,
"the 'iat' must be less than #{5.minutes} seconds old"
end
it "raises Lti::Oauth2::AuthorizationValidator::InvalidAuthJwt if the 'iat' is in the future" do
raw_jwt['iat'] = 10.minutes.from_now.to_i
expect { authValidator.jwt }.to raise_error Lti::Oauth2::AuthorizationValidator::InvalidAuthJwt,
"the 'iat' must not be in the future"
expect { auth_validator.jwt }.to raise_error Lti::Oauth2::AuthorizationValidator::InvalidAuthJwt,
"the 'iat' must not be in the future"
end
it "raises Lti::Oauth2::AuthorizationValidator::InvalidAuthJwt if the 'jti' has already been used" do
enable_cache do
authValidator.jwt
auth_validator.jwt
duplicate_jwt = AuthorizationValidator.new(
jwt: raw_jwt.sign(tool_proxy.shared_secret, :HS256).to_s,
authorization_url: auth_url
@ -134,8 +130,8 @@ module Lti
it "raises Lti::Oauth2::AuthorizationValidator::InvalidAuthJwt if the 'aud' is not the authorization endpoint" do
raw_jwt['aud'] = 'http://google.com/invalid'
expect { authValidator.jwt }.to raise_error Lti::Oauth2::AuthorizationValidator::InvalidAuthJwt,
"the 'aud' must be the LTI Authorization endpoint"
expect { auth_validator.jwt }.to raise_error Lti::Oauth2::AuthorizationValidator::InvalidAuthJwt,
"the 'aud' must be the LTI Authorization endpoint"
end
it "raises Lti::Oauth2::AuthorizationValidator::SecretNotFound if no ToolProxy or developer key" do
@ -148,30 +144,38 @@ module Lti
end
context "JWT signed with dev key" do
let(:authValidator) do
AuthorizationValidator.new(
jwt: raw_jwt_dev_key.sign(dev_key.api_key, :HS256).to_s,
authorization_url: auth_url
)
end
let(:auth_validator) do
AuthorizationValidator.new(
jwt: raw_jwt_dev_key.sign(dev_key.api_key, :HS256).to_s,
authorization_url: auth_url,
code: 'reg_key'
)
end
it "returns the decoded JWT" do
expect(authValidator.jwt.signature).to eq raw_jwt_dev_key.sign(dev_key.api_key, :HS256).signature
end
it 'throws an exception if no code is provided' do
auth_validator = AuthorizationValidator.new(
jwt: raw_jwt_dev_key.sign(dev_key.api_key, :HS256).to_s,
authorization_url: auth_url)
expect { auth_validator.jwt }.to raise_error Lti::Oauth2::AuthorizationValidator::MissingAuthorizationCode
end
it "returns the decoded JWT" do
expect(auth_validator.jwt.signature).to eq raw_jwt_dev_key.sign(dev_key.api_key, :HS256).signature
end
end
end
describe "#developer_key" do
let(:authValidator) do
let(:auth_validator) do
AuthorizationValidator.new(
jwt: raw_jwt_dev_key.sign(dev_key.api_key, :HS256).to_s,
authorization_url: auth_url
authorization_url: auth_url,
code: '123'
)
end
it 'gets the correct developer key' do
expect(authValidator.developer_key).to eq dev_key
expect(auth_validator.developer_key).to eq dev_key
end
it 'returns nil if developer key not found' do
@ -195,7 +199,8 @@ module Lti
it 'returns the developer key global id if dev key is present' do
validator = AuthorizationValidator.new(
jwt: raw_jwt_dev_key.sign(dev_key.api_key, :HS256).to_s,
authorization_url: auth_url
authorization_url: auth_url,
code: '123'
)
expect(validator.sub).to eq dev_key.global_id
end
@ -204,30 +209,30 @@ module Lti
describe "#tool_proxy" do
it 'returns the tool_proxy from the uuid specified in the sub' do
expect(authValidator.tool_proxy).to eq tool_proxy
expect(auth_validator.tool_proxy).to eq tool_proxy
end
it "raises Lti::Oauth2::AuthorizationValidator::InvalidAuthJwt if the Tool Proxy is not using a split secret" do
tool_proxy.stubs(:raw_data).returns({'enabled_capability' => []})
expect { authValidator.jwt }.to raise_error Lti::Oauth2::AuthorizationValidator::InvalidAuthJwt,
"the Tool Proxy must be using a split secret"
expect { auth_validator.jwt }.to raise_error Lti::Oauth2::AuthorizationValidator::InvalidAuthJwt,
"the Tool Proxy must be using a split secret"
end
it "accepts OAuth.splitSecret capability for backwards compatability" do
tool_proxy.stubs(:raw_data).returns({'enabled_capability' => ['OAuth.splitSecret']})
expect(authValidator.tool_proxy).to eq tool_proxy
expect(auth_validator.tool_proxy).to eq tool_proxy
end
it "requires an associated developer_key on the product_family" do
product_family.stubs(:developer_key).returns nil
expect{authValidator.tool_proxy}.to raise_error Lti::Oauth2::AuthorizationValidator::InvalidAuthJwt,
"the Tool Proxy must be associated to a developer key"
expect { auth_validator.tool_proxy }.to raise_error Lti::Oauth2::AuthorizationValidator::InvalidAuthJwt,
"the Tool Proxy must be associated to a developer key"
end
it "requires an associated developer_key on the product_family" do
developer_key.stubs(:active?).returns false
expect{authValidator.tool_proxy}.to raise_error Lti::Oauth2::AuthorizationValidator::InvalidAuthJwt,
"the Developer Key is not active"
dev_key.stubs(:active?).returns false
expect { auth_validator.tool_proxy }.to raise_error Lti::Oauth2::AuthorizationValidator::InvalidAuthJwt,
"the Developer Key is not active"
end
end