ltijs
Version:
Easily turn your web application into a LTI 1.3 Learning Tool.
182 lines • 10 kB
JavaScript
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.DynamicRegistration = void 0;
const http_handler_types_1 = require("#services/http-handler/http-handler.types");
const platform_manager_constants_1 = require("#services/platform-manager/platform-manager.constants");
const id_token_constants_1 = require("#services/launch/id-token.constants");
const lti_scopes_constants_1 = require("#shared/lti-scopes.constants");
const random_1 = require("#utils/random/random");
const validation_1 = require("#utils/validation/validation");
const authorization_header_1 = require("#utils/request/authorization-header");
const dynamic_registration_schemas_1 = require("#services/dynamic-registration/dynamic-registration.schemas");
const errors_1 = require("#services/dynamic-registration/errors");
const dynamic_registration_serializer_1 = require("#services/dynamic-registration/dynamic-registration.serializer");
class DynamicRegistration {
LOG_COMPONENT = 'dynamicRegistrationService';
APPLICATION_TYPE = 'web';
TOKEN_ENDPOINT_AUTH_METHOD = 'private_key_jwt';
TOOL_CONFIGURATION_CLAIM = 'https://purl.imsglobal.org/spec/lti-tool-configuration';
PLATFORM_CONFIGURATION_CLAIM = 'https://purl.imsglobal.org/spec/lti-platform-configuration';
DEFAULT_PLATFORM_NAME = 'Platform';
DEFAULT_DYNAMIC_REGISTRATION_ROUTE = '/lti/register';
OPENID_CONFIGURATION_QUERY_PARAM = 'openid_configuration';
REGISTRATION_TOKEN_QUERY_PARAM = 'registration_token';
FINALIZE_REGISTRATION_HTML_SNIPPET = '<script>(window.opener || window.parent).postMessage({subject:"org.imsglobal.lti.close"}, "*");</script>';
options;
routes;
platformManager;
requestHandler;
httpHandler;
logger;
// Mutable, not `readonly`: reassignable at any time via `setHandler()`
// (and, in turn, via `Provider.onDynamicRegistration()`), matching the
// same default-callback-with-override-setter pattern as `LaunchService`.
handler = this.buildDefaultRouteHandler();
// eslint-disable-next-line @typescript-eslint/max-params
constructor(options, routes, platformManager, requestHandler, httpHandler, logger) {
this.options = options;
this.routes = routes;
this.platformManager = platformManager;
this.requestHandler = requestHandler;
this.httpHandler = httpHandler;
this.logger = logger;
}
prepareHttpRoutes(route = this.DEFAULT_DYNAMIC_REGISTRATION_ROUTE) {
this.httpHandler.registerRoute(route, [http_handler_types_1.HttpMethod.Get], async (request, response) => {
await this.handler(request, response);
});
}
setHandler(handler) {
this.handler = handler;
}
buildDefaultRouteHandler() {
return async (request, response) => {
const query = (0, validation_1.validate)(dynamic_registration_schemas_1.DynamicRegistrationQuerySchema, request.query);
const html = await this.register(query[this.OPENID_CONFIGURATION_QUERY_PARAM], query[this.REGISTRATION_TOKEN_QUERY_PARAM]);
response.html(html);
};
}
async getOpenIDConfiguration(url) {
this.logger.debug(this.LOG_COMPONENT, `Fetching OpenID configuration from ${url}`);
const response = await this.requestHandler.get(url);
return (0, dynamic_registration_serializer_1.buildOpenIDConfiguration)((0, validation_1.validate)(dynamic_registration_schemas_1.OpenIDConfigurationSchema, response.data));
}
async performRegistration(configuration, registrationToken, overrides = {}) {
const options = { ...this.options, ...overrides };
const appUrl = this.buildUrl(options.url, this.routes.appRoute);
const body = this.buildRegistrationRequestBody(configuration, options, appUrl);
this.deepMerge(body, overrides);
this.logger.debug(this.LOG_COMPONENT, `Registering with platform at ${configuration.registration_endpoint}`);
const response = await this.requestHandler.post(configuration.registration_endpoint, body, {
headers: registrationToken !== undefined ? { authorization: (0, authorization_header_1.buildBearerAuthorization)(registrationToken) } : undefined,
});
const registration = (0, validation_1.validate)(dynamic_registration_schemas_1.RegistrationResponseSchema, response.data);
const input = {
url: configuration.issuer,
clientId: registration.client_id,
name: this.resolvePlatformName(configuration, options),
authenticationEndpoint: configuration.authorization_endpoint,
accessTokenEndpoint: configuration.token_endpoint,
authorizationServer: configuration.authorization_server ?? configuration.token_endpoint,
idTokenValidation: { method: platform_manager_constants_1.IdTokenValidationMethod.JwkSet, key: configuration.jwks_uri },
};
let platform = await this.platformManager.registerPlatform(input);
if (options.autoActivate !== true)
platform = await this.platformManager.deactivatePlatform(platform);
this.logger.debug(this.LOG_COMPONENT, `Platform registered: ${platform.id}`);
return platform;
}
async register(openidConfigurationUrl, registrationToken, overrides) {
if (openidConfigurationUrl === undefined)
throw new errors_1.MissingOpenIdConfigurationUrlError();
const configuration = await this.getOpenIDConfiguration(openidConfigurationUrl);
await this.performRegistration(configuration, registrationToken, overrides);
return this.FINALIZE_REGISTRATION_HTML_SNIPPET;
}
resolvePlatformName(configuration, options) {
if (options.platformName !== undefined)
return options.platformName;
const platformConfiguration = configuration[this.PLATFORM_CONFIGURATION_CLAIM];
const productFamilyCode = platformConfiguration?.product_family_code ?? this.DEFAULT_PLATFORM_NAME;
return `${productFamilyCode}_DynReg_${(0, random_1.randomUuid)()}`;
}
buildRegistrationRequestBody(configuration, options, appUrl) {
const messages = [
this.buildMessage(id_token_constants_1.LtiMessageType.ResourceLinkRequest, options.resourceLinkMessage),
];
if (options.useDeepLinking !== false) {
messages.push(this.buildMessage(id_token_constants_1.LtiMessageType.DeepLinkingRequest, options.deepLinkingMessage));
}
return {
application_type: this.APPLICATION_TYPE,
response_types: ['id_token'],
grant_types: ['implicit', 'client_credentials'],
initiate_login_uri: this.buildUrl(options.url, this.routes.loginRoute),
redirect_uris: [...(options.redirectUris ?? []), appUrl],
client_name: options.name,
jwks_uri: this.buildUrl(options.url, this.routes.keysetRoute),
logo_uri: options.logo,
token_endpoint_auth_method: this.TOKEN_ENDPOINT_AUTH_METHOD,
scope: [
lti_scopes_constants_1.AGS_LINEITEM_READONLY_SCOPE,
lti_scopes_constants_1.AGS_LINEITEM_SCOPE,
lti_scopes_constants_1.AGS_SCORE_SCOPE,
lti_scopes_constants_1.AGS_RESULT_READONLY_SCOPE,
lti_scopes_constants_1.NRPS_CONTEXT_MEMBERSHIP_READONLY_SCOPE,
].join(' '),
[this.TOOL_CONFIGURATION_CLAIM]: {
domain: this.getHostname(options.url),
description: options.description,
target_link_uri: appUrl,
custom_parameters: options.customParameters,
claims: configuration.claims_supported,
messages,
},
};
}
buildMessage(type, override) {
return {
type,
...(override?.targetLinkUri !== undefined && { target_link_uri: override.targetLinkUri }),
...(override?.label !== undefined && { label: override.label }),
...(override?.iconUri !== undefined && { icon_uri: override.iconUri }),
...(override?.customParameters !== undefined && { custom_parameters: override.customParameters }),
...(override?.placements !== undefined && { placements: override.placements }),
};
}
// `new URL(path, baseUrl)` would treat a leading-slash `path` as an absolute-path reference, replacing
// the base's entire path instead of appending to it, silently dropping any path prefix a tool
// deployed behind a reverse proxy or subpath is configured with. Parsing baseUrl and concatenating the
// pathname manually preserves that prefix, matching legacy's own approach.
buildUrl(baseUrl, path) {
if (path === '/')
return baseUrl;
const url = new URL(baseUrl);
url.pathname = url.pathname.replace(/\/$/, '') + path;
return url.toString();
}
getHostname(url) {
return new URL(url).host;
}
// Recursively merges `source` onto `target` in place: nested plain objects merge key by key,
// everything else (primitives, arrays, `null`) replaces the existing value outright. Matches legacy's
// own `deepMergeObjects`, since `overrides` is meant to reproduce its exact merge behavior.
deepMerge(target, source) {
for (const key of Object.keys(source)) {
const sourceValue = source[key];
const targetValue = target[key];
if (this.isPlainObject(sourceValue) && this.isPlainObject(targetValue)) {
this.deepMerge(targetValue, sourceValue);
}
else {
target[key] = sourceValue;
}
}
}
isPlainObject(value) {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
}
exports.DynamicRegistration = DynamicRegistration;
exports.default = DynamicRegistration;
//# sourceMappingURL=dynamic-registration.service.js.map