create jwt access_tokens for LTI2 services
fixes: PLAT-2127 test plan: - Hit the LTI2 Auth Token endpoint to get an AccessToken - The access token should be a canvas signed JWT Change-Id: If09dfc658ecc40fc525b9c49d49110539573e657 Reviewed-on: https://gerrit.instructure.com/99946 Reviewed-by: Weston Dransfield <wdransfield@instructure.com> Tested-by: Jenkins QA-Review: August Thornton <august@instructure.com> Product-Review: Nathan Mills <nathanm@instructure.com>
This commit is contained in:
parent
6546b4834b
commit
85673f081f
|
@ -78,9 +78,9 @@ module Lti
|
|||
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
|
||||
jwt_validator.validate!
|
||||
render json: {
|
||||
access_token: SecureRandom.uuid,
|
||||
access_token: Lti::Oauth2::AccessToken.create_jwt(aud: request.host, sub: jwt_validator.tool_proxy.guid).to_s,
|
||||
token_type: 'bearer',
|
||||
expires_in: Setting.get('lti.oauth2.access_token.expiration', 1.hour.to_s)
|
||||
}
|
||||
|
|
|
@ -326,7 +326,7 @@ module Canvas::Security
|
|||
|
||||
if body[:nbf].present?
|
||||
if timestamp_is_future?(body[:nbf])
|
||||
raise Canvas::Security::TokenInvalid
|
||||
raise Canvas::Security::InvalidToken
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -0,0 +1,78 @@
|
|||
module Lti
|
||||
module Oauth2
|
||||
class AccessToken
|
||||
private_class_method :new
|
||||
|
||||
ISS = 'Canvas'.freeze
|
||||
|
||||
attr_reader :aud, :sub
|
||||
|
||||
def self.create_jwt(aud:, sub:)
|
||||
new(aud: aud, sub: sub)
|
||||
end
|
||||
|
||||
def self.from_jwt(aud:, jwt:)
|
||||
decoded_jwt = Canvas::Security.decode_jwt(jwt)
|
||||
new(aud: aud, sub: decoded_jwt[:sub], jwt: jwt)
|
||||
rescue Canvas::Security::TokenExpired => e
|
||||
raise InvalidTokenError, 'token has expired', e.backtrace
|
||||
rescue StandardError => e
|
||||
raise InvalidTokenError, e
|
||||
end
|
||||
|
||||
def initialize(aud:, sub:, jwt: nil)
|
||||
@_jwt = jwt if jwt
|
||||
@aud = aud
|
||||
@sub = sub
|
||||
end
|
||||
|
||||
def validate!
|
||||
decoded_jwt = Canvas::Security.decode_jwt(jwt)
|
||||
check_required_assertions(decoded_jwt.keys)
|
||||
raise InvalidTokenError, 'invalid iss' if decoded_jwt['iss'] != ISS
|
||||
raise InvalidTokenError, 'invalid aud' if decoded_jwt[:aud] != aud
|
||||
raise InvalidTokenError, 'iat must be in the past' unless Time.zone.at(decoded_jwt['iat']) < Time.zone.now
|
||||
true
|
||||
rescue InvalidTokenError
|
||||
raise
|
||||
rescue Canvas::Security::TokenExpired => e
|
||||
raise InvalidTokenError, 'token has expired', e.backtrace
|
||||
rescue StandardError => e
|
||||
raise InvalidTokenError, e
|
||||
end
|
||||
|
||||
def to_s
|
||||
jwt
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def decoded_jwt
|
||||
@_decoded_jwt ||= Canvas::Security.decode_jwt(jwt)
|
||||
end
|
||||
|
||||
def jwt
|
||||
@_jwt ||= begin
|
||||
body = {
|
||||
iss: ISS,
|
||||
sub: sub,
|
||||
exp: Setting.get('lti.oauth2.access_token.exp', 1.hour).to_i.seconds.from_now,
|
||||
aud: aud,
|
||||
iat: Time.zone.now.to_i,
|
||||
nbf: Setting.get('lti.oauth2.access_token.nbf', 30.seconds).to_i.seconds.ago,
|
||||
jti: SecureRandom.uuid
|
||||
}
|
||||
Canvas::Security.create_jwt(body)
|
||||
end
|
||||
end
|
||||
|
||||
def check_required_assertions(assertion_keys)
|
||||
missing_assertions = (%w(iss sub exp aud iat nbf jti) - assertion_keys)
|
||||
if missing_assertions.present?
|
||||
raise InvalidTokenError, "the following assertions are missing: #{missing_assertions.join(',')}"
|
||||
end
|
||||
end
|
||||
|
||||
end
|
||||
end
|
||||
end
|
|
@ -38,6 +38,7 @@ module Lti
|
|||
validated_jwt
|
||||
end
|
||||
end
|
||||
alias_method :validate!, :jwt
|
||||
|
||||
def tool_proxy
|
||||
@_tool_proxy ||= begin
|
||||
|
|
|
@ -0,0 +1,4 @@
|
|||
module Lti::Oauth2
|
||||
class InvalidTokenError < StandardError
|
||||
end
|
||||
end
|
|
@ -77,7 +77,8 @@ module Lti
|
|||
|
||||
it 'returns an access_token' do
|
||||
post auth_endpoint, params
|
||||
expect(JSON.parse(response.body)['access_token']).not_to be_nil
|
||||
access_token = Lti::Oauth2::AccessToken.create_jwt(aud: @request.host, sub: tool_proxy.guid)
|
||||
expect{access_token.validate!}.not_to raise_error
|
||||
end
|
||||
|
||||
it "allows the use of the 'OAuth.splitSecret'" do
|
||||
|
|
|
@ -0,0 +1,134 @@
|
|||
require File.expand_path(File.dirname(__FILE__) + '/../../../spec_helper')
|
||||
require 'json/jwt'
|
||||
|
||||
module Lti
|
||||
module Oauth2
|
||||
describe AccessToken do
|
||||
|
||||
let(:aud){'http://example.com'}
|
||||
let(:sub) {'12084434-0c58-4058-b8c0-4af2da9c2ef8'}
|
||||
let(:body) do
|
||||
{
|
||||
iss: 'Canvas',
|
||||
sub: sub,
|
||||
exp: 5.minutes.from_now.to_i,
|
||||
aud: aud,
|
||||
iat: Time.zone.now.to_i,
|
||||
nbf: 30.seconds.ago,
|
||||
jti: '34084434-0c58-405a-b8c0-4af2da9c2efd'
|
||||
}
|
||||
end
|
||||
|
||||
describe "#to_s" do
|
||||
let(:access_token) {Lti::Oauth2::AccessToken.create_jwt(aud: aud, sub: sub)}
|
||||
|
||||
it "is signed by the canvas secret" do
|
||||
expect{Canvas::Security.decode_jwt(access_token.to_s)}.to_not raise_error
|
||||
end
|
||||
|
||||
it "has an 'iss' set to 'Canvas'" do
|
||||
expect(Canvas::Security.decode_jwt(access_token.to_s)['iss']).to eq('Canvas')
|
||||
end
|
||||
|
||||
it "has an 'aud' set to the current domain" do
|
||||
expect(Canvas::Security.decode_jwt(access_token.to_s)['aud']).to eq aud
|
||||
end
|
||||
|
||||
it "has an 'exp' that is derived from the settings" do
|
||||
Timecop.freeze do
|
||||
Setting.set('lti.oauth2.access_token.exp', 2.hours)
|
||||
expect(Canvas::Security.decode_jwt(access_token.to_s)['exp']).to eq 2.hours.from_now.to_i
|
||||
end
|
||||
end
|
||||
|
||||
it "has a default 'exp' of 1 hour" do
|
||||
Timecop.freeze do
|
||||
expect(Canvas::Security.decode_jwt(access_token.to_s)['exp']).to eq 1.hours.from_now.to_i
|
||||
end
|
||||
end
|
||||
|
||||
it "has an 'iat' set to the current time" do
|
||||
Timecop.freeze do
|
||||
expect(Canvas::Security.decode_jwt(access_token.to_s)['iat']).to eq Time.zone.now.to_i
|
||||
end
|
||||
end
|
||||
|
||||
it "has a 'nbf' derived from the settings" do
|
||||
Timecop.freeze do
|
||||
Setting.set('lti.oauth2.access_token.nbf', 2.minutes)
|
||||
expect(Canvas::Security.decode_jwt(access_token.to_s)['nbf']).to eq 2.minutes.ago.to_i
|
||||
end
|
||||
end
|
||||
|
||||
it "has a default 'nbf' 30 seconds ago" do
|
||||
Timecop.freeze do
|
||||
expect(Canvas::Security.decode_jwt(access_token.to_s)['nbf']).to eq 30.seconds.ago.to_i
|
||||
end
|
||||
end
|
||||
|
||||
it "has a 'jti' that is uniquely generated" do
|
||||
jti_1 = Canvas::Security.decode_jwt(access_token.to_s)['jti']
|
||||
jti_2 = Canvas::Security.decode_jwt(AccessToken.create_jwt(aud: aud, sub: sub).to_s)['jti']
|
||||
expect(jti_1).not_to eq jti_2
|
||||
end
|
||||
|
||||
it "memoizes the jwt" do
|
||||
expect(access_token.to_s).to eq access_token.to_s
|
||||
end
|
||||
|
||||
it "has a 'sub' that is set to the ToolProxy guid" do
|
||||
expect(Canvas::Security.decode_jwt(access_token.to_s)['sub']).to eq sub
|
||||
end
|
||||
|
||||
end
|
||||
|
||||
describe ".from_jwt" do
|
||||
it "raises an InvalidTokenError if not signed by the correct secret" do
|
||||
invalid_token = Canvas::Security.create_jwt(body, nil, 'invalid')
|
||||
expect{ Lti::Oauth2::AccessToken.from_jwt(aud: aud, jwt: invalid_token)}.to raise_error InvalidTokenError
|
||||
end
|
||||
end
|
||||
|
||||
describe "#validate!" do
|
||||
let(:token) {Canvas::Security.create_jwt(body)}
|
||||
let(:access_token) {Lti::Oauth2::AccessToken.from_jwt(aud: aud, jwt: token)}
|
||||
|
||||
it "returns true if there are no errors" do
|
||||
expect(access_token.validate!).to eq true
|
||||
end
|
||||
|
||||
it "raises InvalidTokenError if any of the assertions are missing" do
|
||||
body.delete :jti
|
||||
expect { access_token.validate! }.to raise_error InvalidTokenError, "the following assertions are missing: jti"
|
||||
end
|
||||
|
||||
it "raises an InvalidTokenError if 'iss' is not 'Canvas'" do
|
||||
body[:iss] = 'invalid iss'
|
||||
expect{ access_token.validate! }.to raise_error InvalidTokenError, 'invalid iss'
|
||||
end
|
||||
|
||||
it "raises an InvalidTokenError if the 'exp' is in the past" do
|
||||
body[:exp] = 1.hour.ago
|
||||
expect{ access_token.validate! }.to raise_error InvalidTokenError, 'token has expired'
|
||||
end
|
||||
|
||||
it "raises an InvalidTokenError if the 'aud' is different than the passed in 'aud'" do
|
||||
body[:aud] = 'invalid aud'
|
||||
expect{ access_token.validate! }.to raise_error InvalidTokenError, 'invalid aud'
|
||||
end
|
||||
|
||||
it "raises an InvalidTokenError if the 'iat' is in the future" do
|
||||
body[:iat] = 1.hour.from_now
|
||||
expect{ access_token.validate! }.to raise_error InvalidTokenError, 'iat must be in the past'
|
||||
end
|
||||
|
||||
it "raises an InvalidTokenError if the 'nbf' is in the future" do
|
||||
body[:nbf] = 1.hour.from_now
|
||||
expect{ access_token.validate! }.to raise_error InvalidTokenError
|
||||
end
|
||||
|
||||
end
|
||||
|
||||
end
|
||||
end
|
||||
end
|
Loading…
Reference in New Issue