UNPKG

ltijs

Version:

Easily turn your web application into a LTI 1.3 Learning Tool.

380 lines • 20.1 kB
"use strict"; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.MongoLegacyDatabaseManager = void 0; const mongoose_1 = __importDefault(require("mongoose")); const node_crypto_1 = require("node:crypto"); const aes_encryption_1 = require("#utils/crypto/aes-encryption"); const validation_1 = require("#utils/validation/validation"); const random_1 = require("#utils/random/random"); const id_token_constants_1 = require("#services/launch/id-token.constants"); const mongo_legacy_database_manager_schemas_1 = require("#services/database-manager/mongo-legacy/mongo-legacy-database-manager.schemas"); const database_schemas_1 = require("#services/database-manager/mongo-legacy/database-schemas"); const errors_1 = require("#services/database-manager/mongo-legacy/errors"); /** * A fully standalone `DatabaseManager` implementation reading/writing the * real, existing legacy collection structure `src/utils/Database.js` always * used: platform base fields, status, and keys live in four separate * collections (`platform`/`platformStatus`/`publickey`/`privatekey`) and are * merged into (or decomposed from) a single `PlatformRecord`; the raw * id_token is split across `idToken` (identity claims) and `contexttoken` * (context/resource claims) and merged into (or decomposed from) a single * `IdTokenRecord`. `publickey`/`privatekey`/`accesstoken` stay encrypted via * an `encryptionKey` constructor parameter, exactly as before. * * Deliberately independent of `../mongo/mongo-database-manager.service.ts` * (no shared imports, no inheritance) -- `mongo/` and `mongo-legacy/` are * treated as isolated implementations that could each be extracted into * their own package, so this class duplicates the generic Mongoose * connection-lifecycle logic rather than inheriting it. */ class MongoLegacyDatabaseManager { LOG_COMPONENT = 'database'; CONNECTED_EVENT = 'connected'; OPEN_EVENT = 'open'; ERROR_EVENT = 'error'; RECONNECTED_EVENT = 'reconnected'; DISCONNECTED_EVENT = 'disconnected'; url; connectionOptions; logger; encryptionKey; deployed = false; /** * Note the extra `encryptionKey` parameter -- unlike the default `MongoDatabaseManager`'s two-arg * constructor, this one requires the same key legacy's `Provider.setup('LTIKEY', ...)` used, so it can * decrypt the existing `publickey`/`privatekey`/`accesstoken` documents it reads. */ constructor(logger, config, encryptionKey) { if (config === undefined || config.url === '') throw new errors_1.MissingDatabaseConfigError(); this.url = config.url; if (config.debug === true) mongoose_1.default.set('debug', true); this.connectionOptions = { connectTimeoutMS: 300000, ...config.connection }; this.logger = logger; this.encryptionKey = encryptionKey; } async listen() { const connection = mongoose_1.default.connection; connection.removeAllListeners(this.CONNECTED_EVENT); connection.removeAllListeners(this.ERROR_EVENT); connection.removeAllListeners(this.RECONNECTED_EVENT); connection.removeAllListeners(this.DISCONNECTED_EVENT); connection.on(this.CONNECTED_EVENT, () => { this.logger.debug(this.LOG_COMPONENT, 'Database connected'); }); connection.once(this.OPEN_EVENT, () => { this.logger.debug(this.LOG_COMPONENT, 'Database connection open'); }); connection.on(this.ERROR_EVENT, () => { mongoose_1.default.disconnect().catch(() => undefined); }); connection.on(this.RECONNECTED_EVENT, () => { this.logger.debug(this.LOG_COMPONENT, 'Database reconnected'); }); connection.on(this.DISCONNECTED_EVENT, () => { this.logger.debug(this.LOG_COMPONENT, 'Database disconnected'); this.logger.debug(this.LOG_COMPONENT, 'Attempting to reconnect'); setTimeout(() => { if (connection.readyState === mongoose_1.default.ConnectionStates.disconnected) { mongoose_1.default.connect(this.url, this.connectionOptions).catch((err) => { this.logger.error(this.LOG_COMPONENT, `Error in MongoDb connection: ${String(err)}`); }); } }, 1000); }); if (connection.readyState === mongoose_1.default.ConnectionStates.disconnected) { await mongoose_1.default.connect(this.url, this.connectionOptions); } this.deployed = true; } async close() { mongoose_1.default.connection.removeAllListeners(); await mongoose_1.default.connection.close(); this.deployed = false; } async getPlatformByUrlAndClientId(url, clientId) { this.ensureDeployed(); const doc = await database_schemas_1.LegacyPlatformModel.findOne({ platformUrl: url, clientId }).lean(); if (doc === null) return undefined; return await this.toPlatformRecord(doc); } async getPlatforms(filter = {}) { this.ensureDeployed(); const docs = await database_schemas_1.LegacyPlatformModel.find(this.buildLegacyPlatformQuery(filter)).lean(); return await Promise.all(docs.map(async (doc) => await this.toPlatformRecord(doc))); } async getPlatformById(id) { this.ensureDeployed(); const doc = await database_schemas_1.LegacyPlatformModel.findOne({ kid: id }).lean(); if (doc === null) return undefined; return await this.toPlatformRecord(doc); } async savePlatform(platform) { this.ensureDeployed(); const kid = await this.generateUniqueKid(); const doc = { platformUrl: platform.url, platformName: platform.name, clientId: platform.clientId, authEndpoint: platform.authenticationEndpoint, accesstokenEndpoint: platform.accessTokenEndpoint, authorizationServer: platform.authorizationServer, kid, authConfig: platform.idTokenValidation, }; await Promise.all([ database_schemas_1.LegacyPlatformModel.create(doc), database_schemas_1.PlatformStatusModel.create({ id: kid, active: platform.active }), this.saveEncryptedKey(database_schemas_1.PublicKeyModel, kid, platform.url, platform.clientId, platform.keys.public), this.saveEncryptedKey(database_schemas_1.PrivateKeyModel, kid, platform.url, platform.clientId, platform.keys.private), ]); return kid; } async updatePlatformById(id, fields) { this.ensureDeployed(); const { active, keys, url, clientId, name, authenticationEndpoint, accessTokenEndpoint, idTokenValidation, authorizationServer, } = fields; const legacyUpdate = {}; if (url !== undefined) legacyUpdate.platformUrl = url; if (clientId !== undefined) legacyUpdate.clientId = clientId; if (name !== undefined) legacyUpdate.platformName = name; if (authenticationEndpoint !== undefined) legacyUpdate.authEndpoint = authenticationEndpoint; if (accessTokenEndpoint !== undefined) legacyUpdate.accesstokenEndpoint = accessTokenEndpoint; if (authorizationServer !== undefined) legacyUpdate.authorizationServer = authorizationServer; if (idTokenValidation !== undefined) legacyUpdate.authConfig = idTokenValidation; const writes = []; if (Object.keys(legacyUpdate).length > 0) { writes.push(database_schemas_1.LegacyPlatformModel.updateOne({ kid: id }, { $set: legacyUpdate })); } if (active !== undefined) writes.push(database_schemas_1.PlatformStatusModel.updateOne({ id }, { $set: { active } }, { upsert: true })); // Real legacy cascades a `url`/`clientId` change onto the key docs' own // denormalized copies of those fields (`Provider.js`'s `updatePlatformById`). if (url !== undefined || clientId !== undefined) { const keyUpdate = {}; if (url !== undefined) keyUpdate.platformUrl = url; if (clientId !== undefined) keyUpdate.clientId = clientId; writes.push(database_schemas_1.PublicKeyModel.updateOne({ kid: id }, { $set: keyUpdate })); writes.push(database_schemas_1.PrivateKeyModel.updateOne({ kid: id }, { $set: keyUpdate })); } if (keys !== undefined) { const platform = await database_schemas_1.LegacyPlatformModel.findOne({ kid: id }).lean(); const platformUrl = url ?? platform?.platformUrl ?? ''; const platformClientId = clientId ?? platform?.clientId ?? ''; writes.push(this.saveEncryptedKey(database_schemas_1.PublicKeyModel, id, platformUrl, platformClientId, keys.public)); writes.push(this.saveEncryptedKey(database_schemas_1.PrivateKeyModel, id, platformUrl, platformClientId, keys.private)); } await Promise.all(writes); } async deletePlatformById(id) { this.ensureDeployed(); await Promise.all([ database_schemas_1.LegacyPlatformModel.deleteOne({ kid: id }), database_schemas_1.PlatformStatusModel.deleteOne({ id }), database_schemas_1.PublicKeyModel.deleteOne({ kid: id }), database_schemas_1.PrivateKeyModel.deleteOne({ kid: id }), ]); } async getAccessToken(platformUrl, clientId, scopes) { this.ensureDeployed(); const doc = await database_schemas_1.LegacyAccessTokenModel.findOne({ platformUrl, clientId, scopes }).lean(); if (doc === null) return undefined; const decrypted = this.decryptPayload({ iv: doc.iv, data: doc.data }); const { token } = JSON.parse(decrypted); const merged = { ...token, createdAt: doc.createdAt.getTime() }; return (0, validation_1.validate)(mongo_legacy_database_manager_schemas_1.AccessTokenRecordSchema, merged); } async saveAccessToken(platformUrl, clientId, scopes, token) { this.ensureDeployed(); const { createdAt: _createdAt, ...value } = token; const encrypted = this.encryptPayload(JSON.stringify({ token: value })); // `findOneAndReplace` (not `replaceOne`) so the resulting document -- // whichever it is, freshly inserted or the existing one just replaced -- // comes back with its own real `_id` to return, matching `saveIdToken`'s // own "Mongo generates the id" structure. `AccessTokenRecord` itself has // no `id` field, so nothing re-attaches this to the record on read. const doc = await database_schemas_1.LegacyAccessTokenModel.findOneAndReplace({ platformUrl, clientId, scopes }, { platformUrl, clientId, scopes, ...encrypted }, { upsert: true, returnDocument: 'after' }); return doc._id.toString(); } async getIdToken(id) { this.ensureDeployed(); const [idTokenDoc, contextTokenDoc] = await Promise.all([ database_schemas_1.LegacyIdTokenModel.findOne({ launchId: id }).lean(), database_schemas_1.ContextTokenModel.findOne({ launchId: id }).lean(), ]); if (idTokenDoc === null || contextTokenDoc === null) return undefined; return (0, validation_1.validate)(mongo_legacy_database_manager_schemas_1.IdTokenRecordSchema, this.toIdTokenRecord(idTokenDoc, contextTokenDoc)); } async saveIdToken(token) { this.ensureDeployed(); const launchId = (0, random_1.randomUuid)(); const deploymentId = token[id_token_constants_1.IdTokenClaim.DeploymentId]; const idTokenDoc = { launchId, iss: token.iss, user: token.sub, clientId: token[id_token_constants_1.IdTokenClaim.ClientId], deploymentId, userInfo: { given_name: token.given_name, family_name: token.family_name, name: token.name, email: token.email, }, platformInfo: token[id_token_constants_1.IdTokenClaim.ToolPlatform] ?? {}, platformId: token[id_token_constants_1.IdTokenClaim.PlatformId], }; const contextTokenDoc = { launchId, // Kept for shape-compatibility with the real, existing `contexttoken` // collection -- no longer derived from claim values (see the removed // `buildContextId`), just the same opaque `launchId`. contextId: launchId, user: token.sub, context: token[id_token_constants_1.IdTokenClaim.Context], resource: token[id_token_constants_1.IdTokenClaim.ResourceLink], messageType: token[id_token_constants_1.IdTokenClaim.MessageType], version: token[id_token_constants_1.IdTokenClaim.Version], deepLinkingSettings: token[id_token_constants_1.IdTokenClaim.DeepLinkingSettings], lis: token[id_token_constants_1.IdTokenClaim.Lis], roles: token[id_token_constants_1.IdTokenClaim.Roles], targetLinkUri: token[id_token_constants_1.IdTokenClaim.TargetLinkUri], custom: token[id_token_constants_1.IdTokenClaim.Custom], launchPresentation: token[id_token_constants_1.IdTokenClaim.LaunchPresentation], endpoint: token[id_token_constants_1.IdTokenClaim.Endpoint], namesRoles: token[id_token_constants_1.IdTokenClaim.NamesRoleService], }; await Promise.all([database_schemas_1.LegacyIdTokenModel.create(idTokenDoc), database_schemas_1.ContextTokenModel.create(contextTokenDoc)]); return launchId; } async consumeNonce(nonce) { this.ensureDeployed(); const result = await database_schemas_1.NonceModel.deleteOne({ nonce }); return result.deletedCount > 0; } async saveNonce(nonce) { this.ensureDeployed(); const doc = await new database_schemas_1.NonceModel({ nonce }).save(); return doc._id.toString(); } ensureDeployed() { if (!this.deployed) throw new errors_1.ProviderNotDeployedError(); } // `kid` is real legacy's own platform-identity field -- generated the same // way real legacy's `Auth.generatePlatformKeyPair` did (16 random bytes, // hex-encoded, collision-checked), just scoped to id generation alone now // that key generation itself is a separate concern (`generateKeyPair()`). async generateUniqueKid() { let kid = (0, node_crypto_1.randomBytes)(16).toString('hex'); while ((await database_schemas_1.LegacyPlatformModel.exists({ kid })) !== null) { kid = (0, node_crypto_1.randomBytes)(16).toString('hex'); } return kid; } buildLegacyPlatformQuery(filter) { const query = {}; if (filter.url !== undefined) query.platformUrl = filter.url; if (filter.name !== undefined) query.platformName = filter.name; if (filter.clientId !== undefined) { query.clientId = Array.isArray(filter.clientId) ? { $in: filter.clientId } : filter.clientId; } return query; } async toPlatformRecord(doc) { const [status, publicKey, privateKey] = await Promise.all([ database_schemas_1.PlatformStatusModel.findOne({ id: doc.kid }).lean(), this.getEncryptedKey(database_schemas_1.PublicKeyModel, doc.kid), this.getEncryptedKey(database_schemas_1.PrivateKeyModel, doc.kid), ]); return (0, validation_1.validate)(mongo_legacy_database_manager_schemas_1.PlatformRecordSchema, { id: doc.kid, url: doc.platformUrl, clientId: doc.clientId, name: doc.platformName, authenticationEndpoint: doc.authEndpoint, accessTokenEndpoint: doc.accesstokenEndpoint, authorizationServer: doc.authorizationServer, idTokenValidation: doc.authConfig, // Real legacy's `Platform.platformActive()` treats a missing status // document as active by default (`Platform.js`'s `!platformStatus || // platformStatus[0].active`), not inactive. active: status?.active ?? true, keys: { public: publicKey ?? '', private: privateKey ?? '' }, }); } // Returns a plain object, not `IdTokenRecord` -- `contextTokenDoc`'s fields // are optional on `ContextTokenDoc` (legacy data isn't guaranteed to have // them), so whether the required LTI claims (message_type/version/roles/ // target_link_uri) actually ended up present is `validate()`'s job, not a // compile-time guarantee this builder can make. toIdTokenRecord(idTokenDoc, contextTokenDoc) { return { id: idTokenDoc.launchId, iss: idTokenDoc.iss, sub: idTokenDoc.user, [id_token_constants_1.IdTokenClaim.ClientId]: idTokenDoc.clientId, [id_token_constants_1.IdTokenClaim.PlatformId]: idTokenDoc.platformId, given_name: idTokenDoc.userInfo?.given_name, family_name: idTokenDoc.userInfo?.family_name, name: idTokenDoc.userInfo?.name, email: idTokenDoc.userInfo?.email, [id_token_constants_1.IdTokenClaim.DeploymentId]: idTokenDoc.deploymentId, [id_token_constants_1.IdTokenClaim.ToolPlatform]: idTokenDoc.platformInfo, [id_token_constants_1.IdTokenClaim.MessageType]: contextTokenDoc.messageType, [id_token_constants_1.IdTokenClaim.Version]: contextTokenDoc.version, [id_token_constants_1.IdTokenClaim.Roles]: contextTokenDoc.roles, [id_token_constants_1.IdTokenClaim.TargetLinkUri]: contextTokenDoc.targetLinkUri, [id_token_constants_1.IdTokenClaim.Context]: contextTokenDoc.context, [id_token_constants_1.IdTokenClaim.ResourceLink]: contextTokenDoc.resource, [id_token_constants_1.IdTokenClaim.LaunchPresentation]: contextTokenDoc.launchPresentation, [id_token_constants_1.IdTokenClaim.Custom]: contextTokenDoc.custom, [id_token_constants_1.IdTokenClaim.Lis]: contextTokenDoc.lis, [id_token_constants_1.IdTokenClaim.Endpoint]: contextTokenDoc.endpoint, [id_token_constants_1.IdTokenClaim.NamesRoleService]: contextTokenDoc.namesRoles, [id_token_constants_1.IdTokenClaim.DeepLinkingSettings]: contextTokenDoc.deepLinkingSettings, }; } async getEncryptedKey(model, id) { this.ensureDeployed(); const doc = await model.findOne({ kid: id }).lean(); if (doc === null) return undefined; const decrypted = this.decryptPayload({ iv: doc.iv, data: doc.data }); const { key } = JSON.parse(decrypted); return key; } // eslint-disable-next-line @typescript-eslint/max-params -- mirrors save{Public,Private}Key(id, platformUrl, clientId, key)'s own 4 params, plus the shared model async saveEncryptedKey(model, id, platformUrl, clientId, key) { this.ensureDeployed(); const encrypted = this.encryptPayload(JSON.stringify({ key, kid: id })); await model.replaceOne({ kid: id }, { kid: id, platformUrl, clientId, ...encrypted }, { upsert: true }); } encryptPayload(data) { return (0, aes_encryption_1.encryptAes256)(data, this.encryptionKey); } decryptPayload(payload) { return (0, aes_encryption_1.decryptAes256)(payload.data, payload.iv, this.encryptionKey); } } exports.MongoLegacyDatabaseManager = MongoLegacyDatabaseManager; exports.default = MongoLegacyDatabaseManager; //# sourceMappingURL=mongo-legacy-database-manager.service.js.map