@annoto/ims-lti
Version:
Module for building an LTI Tool Provider and accept LTI launch requests
213 lines (188 loc) • 6.83 kB
JavaScript
/*
* decaffeinate suggestions:
* DS101: Remove unnecessary use of Array.from
* DS102: Remove unnecessary code created because of implicit returns
* DS205: Consider reworking code to avoid use of IIFEs
* DS207: Consider shorter variations of null checks
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
*/
const HMAC_SHA1 = require('./hmac-sha1');
const MemoryNonceStore = require('./memory-nonce-store');
const errors = require('./errors');
const extensions = require('./extensions');
class Provider {
constructor(consumer_key, consumer_secret, nonceStore, signature_method) {
this.valid_request = this.valid_request.bind(this);
this.parse_request = this.parse_request.bind(this);
if (signature_method == null) {
signature_method = new HMAC_SHA1();
}
if (typeof consumer_key === 'undefined' || consumer_key === null) {
throw new errors.ConsumerError('Must specify consumer_key');
}
if (typeof consumer_secret === 'undefined' || consumer_secret === null) {
throw new errors.ConsumerError('Must specify consumer_secret');
}
if (!nonceStore) {
nonceStore = new MemoryNonceStore();
}
if (
!(typeof nonceStore.isNonceStore === 'function'
? nonceStore.isNonceStore()
: undefined)
) {
throw new errors.ParameterError(
'Fourth argument must be a nonceStore object'
);
}
this.consumer_key = consumer_key;
this.consumer_secret = consumer_secret;
this.signer = signature_method;
this.nonceStore = nonceStore;
this.body = {};
}
// Verify parameter and OAuth signature by passing the request object
// Returns true/false if is valid
//
// Sets up request variables for easier access down the line
valid_request(req, body, callback) {
if (!callback) {
callback = body;
body = undefined;
}
body = body || req.body || req.payload;
callback = callback || function() {};
this.parse_request(req, body);
if (!this._valid_parameters(body)) {
return callback(
new errors.ParameterError('Invalid LTI parameters'),
false
);
}
return this._valid_oauth(req, body, callback);
}
// Helper to validate basic LTI parameters
//
// Returns true/false if is valid LTI request
_valid_parameters(body) {
if (!body) {
return false;
}
const correct_version =
require('./ims-lti').supported_versions.indexOf(body.lti_version) !== -1;
const has_resource_link_id = body.resource_link_id != null;
const omits_content_item_params =
body.resource_link_id == null &&
body.resource_link_title == null &&
body.resource_link_description == null &&
body.launch_presentation_return_url == null &&
body.lis_result_sourcedid == null;
return (
(correct_version &&
(body.lti_message_type === 'basic-lti-launch-request' &&
has_resource_link_id)) ||
(body.lti_message_type === 'ContentItemSelectionRequest' &&
omits_content_item_params)
);
}
// Helper to validate the OAuth information in the request
//
// Returns true/false if is valid OAuth signatue and nonce
_valid_oauth(req, body, callback) {
const generated = this.signer.build_signature(
req,
body,
this.consumer_secret
);
const valid_signature = generated === body.oauth_signature;
if (!valid_signature) {
return callback(new errors.SignatureError('Invalid Signature'), false);
}
return this.nonceStore.isNew(
body.oauth_nonce,
body.oauth_timestamp,
function(err, valid) {
if (!valid) {
return callback(new errors.NonceError('Expired nonce'), false);
} else {
return callback(null, true);
}
}
);
}
// Stores the request's properties into the @body accessor
// Strips 'oauth_' parameters for saftey
//
// Does not return anything
parse_request(req, body) {
body = body || req.body || req.payload;
for (let key in body) {
const val = body[key];
if (key.match(/^oauth_/)) {
continue;
}
this.body[key] = val;
}
if (typeof this.body.roles === 'string') {
this.body.roles = this.body.roles.split(',');
}
this.admin = this.has_role('Administrator');
this.alumni = this.has_role('Alumni');
this.content_developer = this.has_role('ContentDeveloper');
this.guest = this.has_role('Guest');
this.instructor =
this.has_role('Instructor') ||
this.has_role('Faculty') ||
this.has_role('Staff');
this.manager = this.has_role('Manager');
this.member = this.has_role('Member');
this.mentor = this.has_role('Mentor');
this.none = this.has_role('None');
this.observer = this.has_role('Observer');
this.other = this.has_role('Other');
this.prospective_student = this.has_role('ProspectiveStudent');
this.student = this.has_role('Learner') || this.has_role('Student');
this.ta = this.has_role('TeachingAssistant');
this.launch_request =
this.body.lti_message_type === 'basic-lti-launch-request';
// user
this.username =
this.body.lis_person_name_given ||
this.body.lis_person_name_family ||
this.body.lis_person_name_full ||
'';
this.userId = this.body.user_id;
if (typeof this.body.role_scope_mentor === 'string') {
this.mentor_user_ids = this.body.role_scope_mentor
.split(',')
.map(id => decodeURIComponent(id));
}
// Context information
this.context_id = this.body.context_id;
this.context_label = this.body.context_label;
this.context_title = this.body.context_title;
// Load up the extensions!
return (() => {
const result = [];
for (let extension_name in extensions) {
const extension = extensions[extension_name];
result.push(extension.init(this));
}
return result;
})();
}
// has_role Helper
has_role(role) {
// There's 3 different types of roles: system, institution, and context. Each one has their own unique identifier
// string within the urn prefix. This regular expression can verify the prefix is there at all, and if it is, ensure
// that it matches one of the three different ways that it can be formatted. Additionally, context roles can have a
// suffix that futher describes what the role may be (such as an instructor that is a lecturer). Those details are
// probably a bit too specific for most cases, so we can just verify that they are optionally there.
const regex = new RegExp(
`^(urn:lti:(sys|inst)?role:ims/lis/)?${role}(/.+)?$`,
'i'
);
return this.body.roles && this.body.roles.some(r => regex.test(r));
}
}
module.exports = Provider;