Skip to content

Commit

Permalink
endpoint to exchange lti2 jwt for access token
Browse files Browse the repository at this point in the history
fixes PLAT-1966

test plan:

create JWT folowing the documentation
you should get an access token

Change-Id: I5e159fb4fd4e40174a3f8712013cd43b3582ee0e
Reviewed-on: https://gerrit.instructure.com/99608
QA-Review: August Thornton <[email protected]>
Tested-by: Jenkins
Reviewed-by: Weston Dransfield <[email protected]>
Product-Review: Nathan Mills <[email protected]>
  • Loading branch information
rivernate committed Jan 24, 2017
1 parent 81b839a commit cfdad0b
Show file tree
Hide file tree
Showing 7 changed files with 503 additions and 0 deletions.
91 changes: 91 additions & 0 deletions app/controllers/lti/ims/authorization_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
require 'json/jwt'

module Lti
module Ims
# @API LTI 2 Authorization
# @internal
# The LTI 2 authorization server is used to retrieve an access token that can be used to access
# other LTI 2 services.
#
# @model AuthorizationJWT
# {
# "id": "AuthorizationJWT",
# "description": "This is a JWT (https://tools.ietf.org/html/rfc7519), we highly recommend using a library to create these tokens. The token should be signed with the shared secret found in the Tool Proxy, which must be using the 'splitSecret' capability. You will also need to set the 'kid' (keyId) in the header of the JWT to equal the Tool Proxy GUID",
# "properties": {
# "iss":{
# "description": "The Tool Proxy Guid",
# "example": "81c4fc5f-4931-4199-ae3b-2077de8f9325",
# "type": "string"
# },
# "sub":{
# "description": "The Tool Proxy Guid",
# "example": "81c4fc5f-4931-4199-ae3b-2077de8f9325",
# "type": "string"
# },
# "aud":{
# "description": "The LTI 2 token authorization endpoint, can be found in the Tool Consumer Profile",
# "example": "https://example.com/api/lti/authorize",
# "type": "string"
# },
# "exp":{
# "description": "When this token expires, should be no more than 1 minute in the future",
# "example": 1484685900,
# "type": "integer"
# },
# "iat":{
# "description": "The time this token was created",
# "example": 1484685847,
# "type": "integer"
# },
# "jti":{
# "description": "A unique ID for this token. Should be a UUID",
# "example": "146dd925-f9ad-4703-a99e-3872000f2534",
# "type": "string"
# }
# }
# }
#
class AuthorizationController < ApplicationController

class InvalidGrant < RuntimeError; end
JWT_GRANT_TYPE = 'urn:ietf:params:oauth:grant-type:jwt-bearer'.freeze

rescue_from JSON::JWS::VerificationFailed,
JSON::JWT::InvalidFormat,
JSON::JWS::UnexpectedAlgorithm,
Lti::Oauth2::AuthorizationValidator::InvalidAuthJwt,
Lti::Oauth2::AuthorizationValidator::ToolProxyNotFound,
InvalidGrant do
render json: {error: 'invalid_grant'}, status: :bad_request
end
# @API authorize
#
# 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"
#
# @argument assertion [Required, AuthorizationJWT]
# The AuthorizationJWT here should be the JWT in a string format
#
# @example_request
# curl https://<canvas>/api/lti/authorize \
# -F 'grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer' \
# -F 'assertion=<AuthorizationJWT>'
#
# @returns [AccessToken]
def authorize
raise InvalidGrant if params[:grant_type] != JWT_GRANT_TYPE
raise InvalidGrant if params[:assertion].blank?
jwt_validator = Lti::Oauth2::AuthorizationValidator.new(jwt: params[:assertion], authorization_url: lti_oauth2_authorize_url)
jwt_validator.jwt
render json: {
access_token: SecureRandom.uuid,
token_type: 'bearer',
expires_in: Setting.get('lti.oauth2.access_token.expiration', 1.hour.to_s)
}
end

end
end
end
6 changes: 6 additions & 0 deletions app/models/lti/tool_consumer_profile_creator.rb
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,12 @@ class ToolConsumerProfileCreator
format: ['application/vnd.ims.lti.v2.toolproxy+json'],
action: ['POST']
},
{
id: 'vnd.Canvas.authorization',
endpoint: ->(context) { "api/lti/authorize" },
format: ['application/json'],
action: ['POST']
},
{
id: 'ToolProxy.item',
endpoint: 'api/lti/tool_proxy/{tool_proxy_guid}',
Expand Down
1 change: 1 addition & 0 deletions config/routes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -1935,6 +1935,7 @@
end

ApiRouteSet.draw(self, "/api/lti") do
post "authorize", controller: 'lti/ims/authorization', action: :authorize, as: 'lti_oauth2_authorize'
%w(course account).each do |context|
prefix = "#{context}s/:#{context}_id"
get "#{prefix}/tool_consumer_profile/:tool_consumer_profile_id", controller: 'lti/ims/tool_consumer_profile',
Expand Down
103 changes: 103 additions & 0 deletions lib/lti/oauth2/authorization_validator.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
require 'json/jwt'

module Lti
module Oauth2
class AuthorizationValidator
class InvalidSignature < StandardError
end
class ToolProxyNotFound < StandardError
end
class InvalidAuthJwt < StandardError
end

def initialize(jwt:, authorization_url:)
@raw_jwt = jwt
@authorization_url = authorization_url
end

def jwt
@_jwt ||= begin
validated_jwt = JSON::JWT.decode @raw_jwt, tool_proxy.shared_secret
check_required_assertions(validated_jwt.keys)
%w(iss sub).each do |assertion|
if validated_jwt[assertion] != tool_proxy.guid
raise InvalidAuthJwt, "the '#{assertion}' must be a valid ToolProxy guid"
end
end
if validated_jwt['aud'] != @authorization_url
raise InvalidAuthJwt, "the 'aud' must be the LTI Authorization endpoint"
end
validate_exp(validated_jwt['exp'])
validate_iat(validated_jwt['iat'])
validate_jti(
jti: validated_jwt['jti'],
sub: validated_jwt['sub'],
exp: validated_jwt['exp'],
iat: validated_jwt['iat']
)
validated_jwt
end
end

def tool_proxy
@_tool_proxy ||= begin
tp = ToolProxy.where(guid: unverified_jwt.kid, workflow_state: 'active').first
raise ToolProxyNotFound if tp.blank?
developer_key = tp.product_family.developer_key
raise InvalidAuthJwt, "the Tool Proxy must be associated to a developer key" if developer_key.blank?
raise InvalidAuthJwt, "the Developer Key is not active" unless developer_key.active?
ims_tool_proxy = IMS::LTI::Models::ToolProxy.from_json(tp.raw_data)
if (ims_tool_proxy.enabled_capabilities & ['Security.splitSecret', 'OAuth.splitSecret']).blank?
raise InvalidAuthJwt, "the Tool Proxy must be using a split secret"
end
tp
end
end


private

def check_required_assertions(assertion_keys)
missing_assertions = (%w(iss sub aud exp iat jti) - assertion_keys)
if missing_assertions.present?
raise InvalidAuthJwt, "the following assertions are missing: #{missing_assertions.join(',')}"
end
end

def unverified_jwt
@_unverified_jwt ||= begin
decoded_jwt = JSON::JWT.decode(@raw_jwt, :skip_verification)
raise InvalidAuthJwt, "the 'kid' header is required" if decoded_jwt.kid.blank?
decoded_jwt
end
end

def validate_exp(exp)
exp_time = Time.zone.at(exp)
max_exp_limit = Setting.get('lti.oauth2.authorize.max.expiration', 1.minute.to_s).to_i.seconds
if exp_time > max_exp_limit.from_now
raise InvalidAuthJwt, "the 'exp' must not be any further than #{max_exp_limit.seconds} seconds in the future"
end
raise InvalidAuthJwt, "the JWT has expired" if exp_time < Time.zone.now
end

def validate_iat(iat)
iat_time = Time.zone.at(iat)
max_iat_age = Setting.get('lti.oauth2.authorize.max_iat_age', 5.minutes.to_s).to_i.seconds
if iat_time < max_iat_age.ago
raise Lti::Oauth2::AuthorizationValidator::InvalidAuthJwt, "the 'iat' must be less than #{5.minutes} seconds old"
end
raise Lti::Oauth2::AuthorizationValidator::InvalidAuthJwt, "the 'iat' must not be in the future" if iat_time > Time.zone.now
end

def validate_jti(jti:, sub:, exp:, iat:)
nonce_duration = (exp.to_i - iat.to_i).seconds
nonce_key = "nonce:#{sub}:#{jti}"
unless Lti::Security.check_and_store_nonce(nonce_key, iat, nonce_duration)
raise Lti::Oauth2::AuthorizationValidator::InvalidAuthJwt, "the 'jti' is invalid"
end
end

end
end
end
106 changes: 106 additions & 0 deletions spec/apis/lti/ims/authorization_api_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
require File.expand_path(File.dirname(__FILE__) + '/../../api_spec_helper')
require_dependency "lti/ims/authorization_controller"
require 'json/jwt'

module Lti
module Ims
describe AuthorizationController, type: :request do

let(:account) { Account.new }

let (:developer_key) { DeveloperKey.create(redirect_uri: 'http://example.com/redirect') }

let(:product_family) do
ProductFamily.create(
vendor_code: '123',
product_code: 'abc',
vendor_name: 'acme',
root_account: account,
developer_key: developer_key
)
end
let(:tool_proxy) do
ToolProxy.create!(
context: account,
guid: SecureRandom.uuid,
shared_secret: 'abc',
product_family: product_family,
product_version: '1',
workflow_state: 'active',
raw_data: {'enabled_capability' => ['Security.splitSecret']},
lti_version: '1'
)
end

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,
iat: Time.zone.now.to_i,
jti: SecureRandom.uuid
}
)
raw_jwt.kid = tool_proxy.guid
raw_jwt
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
expect(response.code).to eq '200'
end

it 'includes an expiration' do
Setting.set('lti.oauth2.access_token.expiration', 1.hour.to_s)
post auth_endpoint, params
expect(JSON.parse(response.body)['expires_in']).to eq 1.hour.to_s
end

it 'has a token_type of bearer' do
post auth_endpoint, params
expect(JSON.parse(response.body)['token_type']).to eq 'bearer'
end

it 'returns an access_token' do
post auth_endpoint, params
expect(JSON.parse(response.body)['access_token']).not_to be_nil
end

it "allows the use of the 'OAuth.splitSecret'" do
tool_proxy.raw_data['enabled_capability'].delete('Security.splitSecret')
tool_proxy.raw_data['enabled_capability'] << 'OAuth.splitSecret'
tool_proxy.save!
post auth_endpoint, params
expect(response.code).to eq '200'
end

it "renders a 400 if the JWT format is invalid" do
params[:assertion] = '12ad3.4fgs56'
post auth_endpoint, params
expect(response.code).to eq '400'
end

it "renders a the correct json if the grant_type is invalid" do
params[:assertion] = '12ad3.4fgs56'
post auth_endpoint, params
expect(response.body).to eq({error: 'invalid_grant'}.to_json)
end

end
end
end
end
Loading

0 comments on commit cfdad0b

Please sign in to comment.