forked from instructure/canvas-lms
-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
endpoint to exchange lti2 jwt for access token
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
Showing
7 changed files
with
503 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
Oops, something went wrong.