ltijs
Version:
Easily turn your web application into a LTI 1.3 Learning Tool.
380 lines • 20.1 kB
JavaScript
"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