ltijs
Version:
Easily turn your web application into a LTI 1.3 Learning Tool.
328 lines • 16.9 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.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