UNPKG

ltijs

Version:

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

328 lines • 16.9 kB
"use strict"; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.LaunchService = void 0; const node_path_1 = __importDefault(require("node:path")); const launch_context_service_1 = require("#services/launch/launch-context.service"); const launch_schemas_1 = require("#services/launch/launch.schemas"); const validation_1 = require("#utils/validation/validation"); const template_renderer_1 = require("#utils/templating/template-renderer"); const jwt_1 = require("#utils/crypto/jwt"); const random_1 = require("#utils/random/random"); const jwt_constants_1 = require("#utils/crypto/jwt.constants"); const errors_1 = require("#services/launch/errors"); const errors_2 = require("#services/oidc/errors"); const http_handler_types_1 = require("#services/http-handler/http-handler.types"); const platform_keys_1 = require("#services/platform-manager/platform-keys"); const id_token_constants_1 = require("#services/launch/id-token.constants"); class LaunchService { LOG_COMPONENT = 'launchService'; RECOVERED_STATE_FIELD = 'ltijs_recovered_state'; LOCAL_STORAGE_KEY_PREFIX = 'ltijs_state_'; DEFAULT_LOGIN_ROUTE = '/lti/login'; DEFAULT_LAUNCH_ROUTE = '/lti/launch'; LTIK_TTL_SECONDS = 3600 * 24; LOGIN_REDIRECT_TEMPLATE = node_path_1.default.join(__dirname, 'templates', 'login-redirect.spy'); SIGNED_STATE_FORM_TEMPLATE = node_path_1.default.join(__dirname, 'templates', 'signed-state-form.spy'); DEFAULT_SUCCESS_BODY = 'It works!'; databaseManager; platformManager; accessTokenManager; oidcService; requestHandler; httpHandler; logger; // Mutable, not `readonly`: these are reassignable at any time via the // public setters below (and, in turn, via `Provider`'s `on***` methods), // matching legacy's own default-callback-with-override-setter pattern. onResourceLinkHandler = this.buildDefaultLaunchHandler(); onDeepLinkingHandler = this.buildDefaultLaunchHandler(); onSubmissionReviewHandler = this.buildDefaultLaunchHandler(); onUnregisteredPlatformHandler = this.buildDefaultUnregisteredPlatformHandler(); onInactivePlatformHandler = this.buildDefaultInactivePlatformHandler(); // eslint-disable-next-line @typescript-eslint/max-params constructor(oidcService, platformManager, accessTokenManager, databaseManager, requestHandler, httpHandler, logger) { this.databaseManager = databaseManager; this.platformManager = platformManager; this.accessTokenManager = accessTokenManager; this.oidcService = oidcService; this.requestHandler = requestHandler; this.httpHandler = httpHandler; this.logger = logger; } setOnResourceLinkHandler(handler) { this.onResourceLinkHandler = handler; } setOnDeepLinkingHandler(handler) { this.onDeepLinkingHandler = handler; } setOnSubmissionReviewHandler(handler) { this.onSubmissionReviewHandler = handler; } setOnUnregisteredPlatformHandler(handler) { this.onUnregisteredPlatformHandler = handler; } setOnInactivePlatformHandler(handler) { this.onInactivePlatformHandler = handler; } buildDefaultLaunchHandler() { return async (_context, _request, response) => { response.status(200).html(this.DEFAULT_SUCCESS_BODY); }; } buildDefaultUnregisteredPlatformHandler() { return async (_request, response) => { const error = new errors_1.UnregisteredPlatformError(); response.status(400).json({ error: error.name, message: error.message }); }; } buildDefaultInactivePlatformHandler() { return async (_request, response) => { const error = new errors_1.PlatformNotActivatedError(); response.status(401).json({ error: error.name, message: error.message }); }; } async getLaunchContext(ltik) { const tid = this.peekLtikTid(ltik); const record = await this.databaseManager.getIdToken(tid); if (record === undefined) throw new errors_1.SessionNotFoundError(); const platform = await this.platformManager.getPlatformById(record.platform_id); if (platform === undefined) throw new errors_1.PlatformNotFoundError(); this.verifyLtik(ltik, platform); return this.buildLaunchContext(record, platform, ltik); } prepareHttpRoutes(routes = { loginRoute: this.DEFAULT_LOGIN_ROUTE, launchRoute: this.DEFAULT_LAUNCH_ROUTE }) { this.httpHandler.registerRoute(routes.loginRoute, [http_handler_types_1.HttpMethod.Get, http_handler_types_1.HttpMethod.Post], async (request, response) => { await this.handleLoginRequest(request, response); }); this.registerLaunchRoute(routes.launchRoute); } /** Registers `path` as an additional launch route, dispatching through the same launch handlers as any other. */ registerLaunchRoute(path) { this.httpHandler.registerRoute(path, [http_handler_types_1.HttpMethod.Post], async (request, response) => { await this.handleLaunchRequest(request, response); }); } buildLaunchContext(record, platform, ltik) { return new launch_context_service_1.LaunchContext(record, platform, ltik, this.accessTokenManager, this.requestHandler, this.logger); } buildLtik(tid, platform) { return (0, jwt_1.signJwt)({ tid }, (0, platform_keys_1.resolvePlatformPrivateKey)(platform), { algorithm: jwt_constants_1.RS256_ALGORITHM, expiresIn: this.LTIK_TTL_SECONDS, }); } peekLtikTid(ltik) { const { payload } = (0, jwt_1.decodeToken)(ltik); const tid = payload.tid; if (typeof tid !== 'string' || tid === '') throw new errors_1.InvalidLtikError(); return tid; } verifyLtik(ltik, platform) { try { (0, jwt_1.verifyTokenSignature)(ltik, (0, platform_keys_1.resolvePlatformPublicKey)(platform), [jwt_constants_1.RS256_ALGORITHM]); } catch { throw new errors_1.InvalidLtikError(); } } async handleLoginRequest(request, response) { const params = (0, validation_1.validate)(launch_schemas_1.LoginRequestPayloadSchema, { ...request.query, ...request.body }); let loginResult; try { loginResult = await this.processLogin(params); } catch (error) { // These two overrides fully own the HTTP response themselves: the login route always returns // immediately after invoking one, with no continuation of the OIDC flow. A handler always exists // now (real defaults, see `buildDefaultXHandler()`), so dispatch is unconditional. if (error instanceof errors_1.UnregisteredPlatformError) { await this.onUnregisteredPlatformHandler(request, response); return; } if (error instanceof errors_1.PlatformNotActivatedError) { await this.onInactivePlatformHandler(request, response); return; } throw error; } const { redirectUrl, state, recoveryToken, storageTarget, platformLoginOrigin } = loginResult; const html = (0, template_renderer_1.renderTemplate)(this.LOGIN_REDIRECT_TEMPLATE, { dataJson: this.toDataScript({ key: this.buildLocalStorageKey(state), state, recoveryToken, targetUrl: redirectUrl, storageTarget, platformLoginOrigin, }), }); response.html(html); } async handleLaunchRequest(request, response) { const parameters = (0, validation_1.validate)(launch_schemas_1.LaunchCallbackPayloadSchema, request.body); const recoveredState = this.resolveRecoveredState(request); if (recoveredState === undefined) { const { storageTarget, platformLoginOrigin } = this.peekStateToken(parameters.stateToken); const html = (0, template_renderer_1.renderTemplate)(this.SIGNED_STATE_FORM_TEMPLATE, { dataJson: this.toDataScript({ key: this.buildLocalStorageKey(parameters.stateToken), idToken: parameters.rawIdToken, state: parameters.stateToken, storageTarget, platformLoginOrigin, }), }); response.html(html); return; } const { payload } = (0, jwt_1.decodeToken)(parameters.rawIdToken); const platform = await this.resolveIdTokenPlatform(payload.iss, payload.aud); if (this.isSameOriginRequest(request) === false) throw new errors_2.InvalidStateError(); this.oidcService.verifyRecoveredState(recoveredState, parameters.stateToken, platform); const { idToken, state } = await this.processLaunch(parameters, platform); const ltik = this.buildLtik(idToken.id, platform); const context = this.buildLaunchContext(idToken, platform, ltik); const handler = this.resolveHandler(context.idToken.launch.type); request.query = this.updateQueryWithState(request, state); await handler(context, request, response); } updateQueryWithState(request, state) { if (state.query === undefined) return request.query; return { ...request.query, ...state.query }; } resolveHandler(type) { switch (type) { case id_token_constants_1.LtiMessageType.ResourceLinkRequest: return this.onResourceLinkHandler; case id_token_constants_1.LtiMessageType.DeepLinkingRequest: return this.onDeepLinkingHandler; case id_token_constants_1.LtiMessageType.SubmissionReviewRequest: return this.onSubmissionReviewHandler; } } async processLaunch(parameters, platform) { const { stateToken, rawIdToken } = parameters; const { header } = (0, jwt_1.decodeToken)(rawIdToken); // Independent once `platform` is resolved: validateIdToken is the expensive one (JWKS fetch, // nonce-consuming DB round-trip), so running both concurrently keeps validateStateToken's latency // off the critical path instead of paying for it strictly before the id-token check starts. const [state, idTokenClaims] = await Promise.all([ this.oidcService.validateStateToken(stateToken, platform), this.oidcService.validateIdToken(rawIdToken, header, platform), ]); idTokenClaims[id_token_constants_1.IdTokenClaim.ClientId] = platform.clientId; idTokenClaims[id_token_constants_1.IdTokenClaim.PlatformId] = platform.id; idTokenClaims[id_token_constants_1.IdTokenClaim.TargetLinkUri] = this.resolveTargetLinkUri(idTokenClaims, state); const id = await this.databaseManager.saveIdToken(idTokenClaims); const idToken = { ...idTokenClaims, id }; this.logger.debug(this.LOG_COMPONENT, `Launch processed for [${idToken.iss}]`); return { idToken, state, platform }; } resolveTargetLinkUri(token, state) { const targetLinkUri = token[id_token_constants_1.IdTokenClaim.TargetLinkUri]; if (targetLinkUri === undefined) return undefined; const parts = this.splitTargetLinkUri(targetLinkUri); const restoresFragment = parts.fragment === undefined && state.fragment !== undefined; if (state.query === undefined && !restoresFragment) return targetLinkUri; return this.joinTargetLinkUri({ targetLinkUri: parts.targetLinkUri, query: { ...parts.query, ...state.query }, fragment: parts.fragment ?? state.fragment, }); } async processLogin(params) { const platform = await this.resolvePlatform(params.iss, params.clientId); const { targetLinkUri, ...target } = this.splitTargetLinkUri(params.targetLinkUri); const platformLoginOrigin = params.storageTarget === undefined ? undefined : new URL(platform.authenticationEndpoint).origin; const storage = params.storageTarget === undefined || platformLoginOrigin === undefined ? undefined : { target: params.storageTarget, loginOrigin: platformLoginOrigin }; const stateId = (0, random_1.randomUuid)(); const state = this.oidcService.buildStateToken(platform, target, storage, stateId); const recoveryToken = this.oidcService.buildRecoveryToken(stateId, platform); const redirectUrl = await this.oidcService.buildAuthenticationRequestUrl(platform, { loginHint: params.loginHint, redirectUri: targetLinkUri, state, ltiMessageHint: params.ltiMessageHint, ltiDeploymentId: params.ltiDeploymentId, }); this.logger.debug(this.LOG_COMPONENT, `Login request processed for [${params.iss}]`); return { redirectUrl, state, recoveryToken, storageTarget: params.storageTarget, platformLoginOrigin }; } async resolvePlatform(url, clientId) { const platforms = await this.platformManager.getPlatforms({ url, clientId }); if (platforms.length === 0) throw new errors_1.UnregisteredPlatformError(); const platform = platforms[0]; if (!platform.active) throw new errors_1.PlatformNotActivatedError(); return platform; } async resolveIdTokenPlatform(iss, aud) { // A spec-permitted multi-value `aud` is resolved in one batched query (an `$in`-style clientId // match) instead of one sequential DB round-trip per candidate. The first candidate (in `aud` // order) with a matching platform record wins, same as the old loop, which stopped at the first // registered match and never looked past it even if that match turned out to be inactive. const clientIds = Array.isArray(aud) ? aud : [aud]; const platforms = await this.platformManager.getPlatforms({ url: iss, clientId: clientIds }); const platform = clientIds.map(clientId => platforms.find(p => p.clientId === clientId)).find(p => p !== undefined); if (platform === undefined) throw new errors_1.UnregisteredPlatformError(); if (!platform.active) throw new errors_1.PlatformNotActivatedError(); return platform; } resolveRecoveredState(request) { const bodyState = request.body[this.RECOVERED_STATE_FIELD]; return typeof bodyState === 'string' ? bodyState : undefined; } // `undefined` when the browser didn't send Sec-Fetch-Site at all (older Safari, some network // middleboxes); the caller only rejects on an explicit `false`, not on `undefined`. Not an OIDC // concern (OidcService has no notion of HTTP requests), so it stays here rather than there. isSameOriginRequest(request) { const secFetchSite = request.headers['sec-fetch-site']; if (secFetchSite === undefined) return undefined; return secFetchSite === 'same-origin'; } buildLocalStorageKey(state) { return `${this.LOCAL_STORAGE_KEY_PREFIX}${state}`; } peekStateToken(stateToken) { return (0, jwt_1.decodeToken)(stateToken).payload; } toDataScript(data) { return JSON.stringify(data).replace(/</g, '\\u003c'); } splitTargetLinkUri(targetLinkUri) { // The fragment is cut first: a `?` inside it (hash routing, e.g. `/#/page?a=1`) is not a query. const fragmentIndex = targetLinkUri.indexOf('#'); const fragment = fragmentIndex === -1 ? undefined : targetLinkUri.slice(fragmentIndex + 1); const withoutFragment = fragmentIndex === -1 ? targetLinkUri : targetLinkUri.slice(0, fragmentIndex); const queryIndex = withoutFragment.indexOf('?'); if (queryIndex === -1) return { targetLinkUri: withoutFragment, fragment }; const query = Object.fromEntries(new URLSearchParams(withoutFragment.slice(queryIndex + 1))); return { targetLinkUri: withoutFragment.slice(0, queryIndex), query, fragment }; } joinTargetLinkUri(parts) { const query = new URLSearchParams(parts.query).toString(); const querySuffix = query === '' ? '' : `?${query}`; const fragmentSuffix = parts.fragment === undefined ? '' : `#${parts.fragment}`; return parts.targetLinkUri + querySuffix + fragmentSuffix; } } exports.LaunchService = LaunchService; exports.default = LaunchService; //# sourceMappingURL=launch.service.js.map