canvas-lti
Version:
A Canvas LTI 1.3 integration tool.
299 lines (273 loc) • 13.3 kB
JavaScript
const { getJwks } = require('./utils/jwks.js');
const { jsonConfig } = require('./utils/jsonConfig.js');
const { validateKeys } = require('./utils/validate.js');
const jwt = require('jsonwebtoken');
const bodyParser = require('body-parser');
require('dotenv').config();
const forge = require('node-forge');
const fs = require('fs');
const { Sign, generateKey } = require('crypto');
const Database = require('./utils/db.js');
let configuration = {};
let app; // This will hold an express app instance.
let db; // This will hold a database instance.
const config = (options) => {
// Required properties for installations
const requiredInstallationProps = ['oidcInitiationUrl', 'clientId', 'baseURL', 'launchPath', 'ltiPath', 'name'];
// Required properties for sqlEnvPaths
const requiredSqlEnvPaths = ['user', 'password', 'host', 'database', 'port', 'table'];
// Check if installations array is present and valid
if (!options.installations || !Array.isArray(options.installations) || options.installations.length === 0) {
throw new Error('Missing or invalid installations array');
}
// Check each installation for required properties
options.installations.forEach((installation, index) => {
const missingInstallationProps = requiredInstallationProps.filter(prop => !(prop in installation));
if (missingInstallationProps.length > 0) {
throw new Error(`Missing required properties in installation at index ${index}: ${missingInstallationProps.join(', ')}`);
}
});
// Check if sqlEnvPaths object is present and valid
if (!options.sqlEnvPaths || typeof options.sqlEnvPaths !== 'object') {
throw new Error('Missing or invalid sqlEnvPaths object');
}
// Check for missing sqlEnvPaths properties
const missingSqlEnvPaths = requiredSqlEnvPaths.filter(prop => !(prop in options.sqlEnvPaths));
if (missingSqlEnvPaths.length > 0) {
throw new Error(`Missing required properties in sqlEnvPaths: ${missingSqlEnvPaths.join(', ')}`);
}
// Check if the corresponding environment variables exist in process.env
const missingEnvVars = requiredSqlEnvPaths.filter(prop => !process.env[options.sqlEnvPaths[prop]]);
if (missingEnvVars.length > 0) {
throw new Error(`Missing required environment variables (in .env): ${missingEnvVars.map(prop => options.sqlEnvPaths[prop]).join(', ')}`);
}
// Merge the options with the default configuration
configuration = { ...configuration, ...options };
const table = process.env[options.sqlEnvPaths.table];
const db_name = process.env[options.sqlEnvPaths.database];
let initQueries = [
`CREATE TABLE IF NOT EXISTS \`${db_name}\`.\`${table}\` (
id INT AUTO_INCREMENT PRIMARY KEY,
oidcInitiationUrl VARCHAR(2048) NOT NULL,
clientId VARCHAR(255) NOT NULL,
baseURL VARCHAR(2048) NOT NULL,
launchPath VARCHAR(2048) NOT NULL,
ltiPath VARCHAR(2048) NOT NULL,
name VARCHAR(255) NOT NULL
)`
];
db = new Database(options.sqlEnvPaths, initQueries);
const fetchInstallationsFromDB = async () => {
console.log('Retrieving installations from database...');
const q = `SELECT * FROM \`${db_name}\`.\`${table}\``;
try {
const r = await db.query(q);
return r.map(row => ({
oidcInitiationUrl: row.oidcInitiationUrl,
clientId: row.clientId,
baseURL: row.baseURL,
launchPath: row.launchPath,
ltiPath: row.ltiPath,
name: row.name
}));
} catch (error) {
console.error('Error retrieving installations from database:', error);
throw error;
}
};
const addInstallationToDB = async (installation) => {
console.log('Adding installation to database for', installation.name);
const insertQuery = `
INSERT INTO \`${db_name}\`.\`${table}\`
(oidcInitiationUrl, clientId, baseURL, launchPath, ltiPath, name)
VALUES (?, ?, ?, ?, ?, ?)
`;
try {
await db.query(insertQuery, [
installation.oidcInitiationUrl,
installation.clientId,
installation.baseURL,
installation.launchPath,
installation.ltiPath,
installation.name
]);
console.log('Installation added to database:', installation);
} catch (error) {
console.error('Error inserting installation into database:', error);
}
};
const updateInstallationInDB = async (installation) => {
console.log('Updating installation in database for', installation.name);
const updateQuery = `
UPDATE \`${db_name}\`.\`${table}\`
SET clientId = ?, baseURL = ?, launchPath = ?, ltiPath = ?, name = ?
WHERE oidcInitiationUrl = ?
`;
try {
await db.query(updateQuery, [
installation.clientId,
installation.baseURL,
installation.launchPath,
installation.ltiPath,
installation.name,
installation.oidcInitiationUrl
]);
console.log('Installation updated in database:', installation);
} catch (error) {
console.error('Error updating installation in database:', error);
}
};
const syncInstallations = async () => {
if (!configuration.installations) {
console.log('No installations found in configuration. Retrieving from database...');
configuration.installations = await fetchInstallationsFromDB();
} else {
console.log('Installations found in configuration. Checking for new or updated installations...');
const dbInstallations = await fetchInstallationsFromDB();
for (const installation of configuration.installations) {
const dbInstallation = dbInstallations.find(dbInst => dbInst.oidcInitiationUrl === installation.oidcInitiationUrl);
if (!dbInstallation) {
// Installation does not exist in the database, add it
await addInstallationToDB(installation);
} else {
// Installation exists, check for updates
const isUpdated = ['clientId', 'baseURL', 'launchPath', 'ltiPath', 'name'].some(key => dbInstallation[key] !== installation[key]);
if (isUpdated) {
await updateInstallationInDB(installation);
}
}
}
}
};
syncInstallations()
.then (() => {
db.close();
})
.catch(error => {
console.error('Error during synchronization:', error);
});
}
const lti = {
config,
expressInstance: function(expressInstance) {
app = expressInstance;
app.use(bodyParser.urlencoded({ extended: true }));
configuration.installations.forEach(installation => {
const jwksPath = installation.ltiPath ? `${installation.ltiPath}/.well-known/jwks.json` : '/.well-known/jwks.json';
app.get(jwksPath, async (req, res) => {
try {
const jwks = await getJwks();
res.status(200).json(jwks);
console.log("Sent JWKS");
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Unable to retrieve the JWKS for this application.' });
}
});
const oidcPath = installation.ltiPath ? `${installation.ltiPath}/oidc` : '/oidc';
console.log("Initializing OIDC path: ", oidcPath);
app.post(oidcPath, async (req, res) => {
const { login_hint, lti_message_hint, target_link_uri, client_id } = req.body;
const oidcConfigUrl = installation.oidcInitiationUrl;
const redirectUri = target_link_uri;
const oidcReq = {
client_id: client_id,
login_hint: login_hint,
lti_message_hint: lti_message_hint,
nonce: crypto.randomUUID(),
prompt: 'none',
redirect_uri: redirectUri,
response_mode: 'form_post',
response_type: 'id_token',
scope: 'openid',
state: crypto.randomUUID()
};
const queryString = Object.keys(oidcReq)
.map(key => `${key}=${encodeURIComponent(oidcReq[key])}`)
.join('&');
const fullUrl = `${oidcConfigUrl}?${queryString}`;
res.redirect(fullUrl);
});
});
},
on: (path, handler) => {
configuration.installations.forEach(installation => {
if (!installation.ltiPath.startsWith('/')) installation.ltiPath = `/${installation.ltiPath}`;
if (!installation.ltiPath.endsWith('/')) installation.ltiPath = `${installation.ltiPath}/`;
const fullPath = installation.ltiPath ? `${installation.ltiPath}${path}` : path;
app.post(fullPath, async (req, res) => {
try {
req.installation = installation;
const decoded = jwt.decode(req.body.id_token, { complete: true });
if (decoded.payload['https://purl.imsglobal.org/spec/lti/claim/message_type'] === 'LtiDeepLinkingRequest') {
req.deepLinkingRequest = decoded.payload['https://purl.imsglobal.org/spec/lti-dl/claim/deep_linking_settings'].deep_link_return_url
req.deepLinkData = decoded.payload;
req.kid = decoded.header.kid;
}
await handler(req, res);
} catch (err) {
console.error(err);
res.status(500).send('Error');
}
});
});
},
handleResponse: (req, res, items, html) => {
const deepLinkReturnUrl = req.deepLinkingRequest;
const contentItems = {
"iss": req.deepLinkData.aud,
"aud": 'https://canvas.instructure.com',
"iat": Date.now() / 1000,
"exp": Date.now() / 1000 + 60,
"nonce": crypto.randomUUID(),
"jti": crypto.randomUUID(),
"sub": req.deepLinkData['sub'],
"https://purl.imsglobal.org/spec/lti/claim/deployment_id": req.deepLinkData['https://purl.imsglobal.org/spec/lti/claim/deployment_id'],
"https://purl.imsglobal.org/spec/lti/claim/message_type": 'LtiDeepLinkingResponse',
"https://purl.imsglobal.org/spec/lti/claim/version": req.deepLinkData['https://purl.imsglobal.org/spec/lti/claim/version'],
"https://purl.imsglobal.org/spec/lti-dl/claim/content_items": items,
"https://purl.imsglobal.org/spec/lti-dl/claim/data": req.deepLinkData['https://purl.imsglobal.org/spec/lti-dl/claim/data'] ? req.deepLinkData['https://purl.imsglobal.org/spec/lti-dl/claim/data'] : '',
"https://purl.imsglobal.org/spec/lti/claim/context": req.deepLinkData['https://purl.imsglobal.org/spec/lti/claim/context'],
};
// Assuming privateKey is in PEM format and not encrypted
const privateKey = fs.readFileSync('private.key', 'utf8');
// Sign the JWT
const signedJwt = jwt.sign(contentItems, privateKey, {
algorithm: 'RS256',
});
// Prepare the HTML response
// const htmlResponse = `
// <html>
// <body>
// <form id="autoSubmit" action="${deepLinkReturnUrl}" method="POST">
// <input type="hidden" name="JWT" value="${signedJwt}" />
// </form>
// <script>
// window.onload = function() {
// document.getElementById('autoSubmit').submit();
// };
// </script>
// </body>
// </html>
// `;
html = html.replace("{{RETURN_URL}}", deepLinkReturnUrl);
html = html.replace("{{SIGNED_JWT}}", signedJwt);
// Send the response
const h = fs.read
res.send(html);
},
utils: {
//jsonConfig,
//validateKeys: validateKeys(),
generateKeys: async function() {
const jwks = await getJwks();
if (jwks) {
return true;
} else {
return false;
}
},
}
//jsonConfig,
}
module.exports = lti;