UNPKG

ltijs

Version:

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

182 lines • 10 kB
"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