UNPKG

canvas-lti

Version:

A Canvas LTI 1.3 integration tool.

299 lines (273 loc) 13.3 kB
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;