@lti-tool/core 0.13.1 → 0.13.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +6 -0
- package/dist/ltiTool.d.ts.map +1 -1
- package/dist/ltiTool.js +296 -133
- package/dist/utils/errorFormatting.d.ts +18 -0
- package/dist/utils/errorFormatting.d.ts.map +1 -0
- package/dist/utils/errorFormatting.js +22 -0
- package/package.json +1 -1
- package/src/ltiTool.ts +364 -165
- package/src/utils/errorFormatting.ts +22 -0
package/src/ltiTool.ts
CHANGED
|
@@ -39,6 +39,7 @@ import { DynamicRegistrationService } from './services/dynamicRegistration.servi
|
|
|
39
39
|
import { NRPSService } from './services/nrps.service.js';
|
|
40
40
|
import { createSession } from './services/session.service.js';
|
|
41
41
|
import { TokenService } from './services/token.service.js';
|
|
42
|
+
import { formatError } from './utils/errorFormatting.js';
|
|
42
43
|
import { getValidLaunchConfig } from './utils/launchConfigValidation.js';
|
|
43
44
|
|
|
44
45
|
/**
|
|
@@ -136,51 +137,41 @@ export class LTITool {
|
|
|
136
137
|
lti_deployment_id: string;
|
|
137
138
|
lti_message_hint?: string;
|
|
138
139
|
}): Promise<string> {
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
const authUrl = new URL(launchConfig.authUrl);
|
|
168
|
-
authUrl.searchParams.set('scope', 'openid');
|
|
169
|
-
authUrl.searchParams.set('response_type', 'id_token');
|
|
170
|
-
authUrl.searchParams.set('response_mode', 'form_post');
|
|
171
|
-
authUrl.searchParams.set('prompt', 'none');
|
|
172
|
-
authUrl.searchParams.set('client_id', validatedParams.client_id);
|
|
173
|
-
authUrl.searchParams.set('redirect_uri', validatedParams.launchUrl.toString());
|
|
174
|
-
authUrl.searchParams.set('login_hint', validatedParams.login_hint);
|
|
175
|
-
authUrl.searchParams.set('state', state);
|
|
176
|
-
authUrl.searchParams.set('nonce', nonce);
|
|
177
|
-
authUrl.searchParams.set('lti_deployment_id', validatedParams.lti_deployment_id);
|
|
140
|
+
try {
|
|
141
|
+
const validatedParams = HandleLoginParamsSchema.parse(params);
|
|
142
|
+
|
|
143
|
+
const nonce = crypto.randomUUID();
|
|
144
|
+
|
|
145
|
+
// Store nonce with expiration for replay attack prevention
|
|
146
|
+
const nonceExpirationSeconds = this.config.security?.nonceExpirationSeconds ?? 600;
|
|
147
|
+
const nonceExpiresAt = new Date(Date.now() + nonceExpirationSeconds * 1000);
|
|
148
|
+
await this.config.storage.storeNonce(nonce, nonceExpiresAt);
|
|
149
|
+
|
|
150
|
+
const state = await new SignJWT({
|
|
151
|
+
nonce,
|
|
152
|
+
iss: validatedParams.iss,
|
|
153
|
+
client_id: validatedParams.client_id,
|
|
154
|
+
target_link_uri: validatedParams.target_link_uri,
|
|
155
|
+
exp:
|
|
156
|
+
Math.floor(Date.now() / 1000) +
|
|
157
|
+
(this.config.security?.stateExpirationSeconds ?? 600),
|
|
158
|
+
})
|
|
159
|
+
.setProtectedHeader({ alg: 'HS256' })
|
|
160
|
+
.sign(this.config.stateSecret);
|
|
161
|
+
|
|
162
|
+
const launchConfig = await getValidLaunchConfig(
|
|
163
|
+
this.config.storage,
|
|
164
|
+
validatedParams.iss,
|
|
165
|
+
validatedParams.client_id,
|
|
166
|
+
validatedParams.lti_deployment_id,
|
|
167
|
+
);
|
|
178
168
|
|
|
179
|
-
|
|
180
|
-
|
|
169
|
+
return buildAuthUrl(launchConfig, validatedParams, state, nonce);
|
|
170
|
+
} catch (error) {
|
|
171
|
+
throw new Error(
|
|
172
|
+
`[LTI] Login initiation failed for issuer '${params.iss}', client '${params.client_id}': ${formatError(error)}`,
|
|
173
|
+
);
|
|
181
174
|
}
|
|
182
|
-
|
|
183
|
-
return authUrl.toString();
|
|
184
175
|
}
|
|
185
176
|
|
|
186
177
|
/**
|
|
@@ -199,58 +190,62 @@ export class LTITool {
|
|
|
199
190
|
* @throws {Error} When verification fails for security reasons
|
|
200
191
|
*/
|
|
201
192
|
async verifyLaunch(idToken: string, state: string): Promise<LTI13JwtPayload> {
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
// 1. UNVERIFIED - get issuer
|
|
205
|
-
const unverified = decodeJwt(validatedParams.idToken);
|
|
206
|
-
if (!unverified.iss) {
|
|
207
|
-
throw new Error('No issuer in token');
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
// 2. get the launchConfig so we can get the remote JWKS from our data store
|
|
211
|
-
const launchConfig = await getValidLaunchConfig(
|
|
212
|
-
this.config.storage,
|
|
213
|
-
unverified.iss,
|
|
214
|
-
unverified.aud as string,
|
|
215
|
-
unverified['https://purl.imsglobal.org/spec/lti/claim/deployment_id'] as string,
|
|
216
|
-
);
|
|
217
|
-
|
|
218
|
-
// 3. Verify LMS JWT
|
|
219
|
-
let jwks = this.jwksCache.get(launchConfig.jwksUrl);
|
|
220
|
-
if (!jwks) {
|
|
221
|
-
jwks = createRemoteJWKSet(new URL(launchConfig.jwksUrl));
|
|
222
|
-
this.jwksCache.set(launchConfig.jwksUrl, jwks);
|
|
223
|
-
}
|
|
224
|
-
const { payload } = await jwtVerify(validatedParams.idToken, jwks);
|
|
193
|
+
try {
|
|
194
|
+
const validatedParams = VerifyLaunchParamsSchema.parse({ idToken, state });
|
|
225
195
|
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
// 5. Parse and validate LMS JWT
|
|
233
|
-
const validated = LTI13JwtPayloadSchema.parse(payload);
|
|
196
|
+
// 1. UNVERIFIED - get issuer
|
|
197
|
+
const unverified = decodeJwt(validatedParams.idToken);
|
|
198
|
+
if (!unverified.iss) {
|
|
199
|
+
throw new Error('No issuer in token');
|
|
200
|
+
}
|
|
234
201
|
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
202
|
+
// 2. get the launchConfig so we can get the remote JWKS from our data store
|
|
203
|
+
const launchConfig = await getValidLaunchConfig(
|
|
204
|
+
this.config.storage,
|
|
205
|
+
unverified.iss,
|
|
206
|
+
unverified.aud as string,
|
|
207
|
+
unverified['https://purl.imsglobal.org/spec/lti/claim/deployment_id'] as string,
|
|
239
208
|
);
|
|
240
|
-
}
|
|
241
209
|
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
210
|
+
// 3. Verify LMS JWT
|
|
211
|
+
let jwks = this.jwksCache.get(launchConfig.jwksUrl);
|
|
212
|
+
if (!jwks) {
|
|
213
|
+
jwks = createRemoteJWKSet(new URL(launchConfig.jwksUrl));
|
|
214
|
+
this.jwksCache.set(launchConfig.jwksUrl, jwks);
|
|
215
|
+
}
|
|
216
|
+
const { payload } = await jwtVerify(validatedParams.idToken, jwks);
|
|
217
|
+
|
|
218
|
+
// 4. Verify our state JWT
|
|
219
|
+
const { payload: stateData } = await jwtVerify(
|
|
220
|
+
validatedParams.state,
|
|
221
|
+
this.config.stateSecret,
|
|
222
|
+
);
|
|
246
223
|
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
224
|
+
// 5. Parse and validate LMS JWT
|
|
225
|
+
const validated = LTI13JwtPayloadSchema.parse(payload);
|
|
226
|
+
|
|
227
|
+
// 6. Verify client id matches (audience claim)
|
|
228
|
+
if (validated.aud !== launchConfig.clientId) {
|
|
229
|
+
throw new Error(
|
|
230
|
+
`Invalid client_id: expected ${launchConfig.clientId}, got ${validated.aud}`,
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// 7. Verify nonce matches
|
|
235
|
+
if (stateData.nonce !== validated.nonce) {
|
|
236
|
+
throw new Error('Nonce mismatch');
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// 8. Check nonce hasn't been used before (prevent replay attacks)
|
|
240
|
+
const isValidNonce = await this.config.storage.validateNonce(validated.nonce);
|
|
241
|
+
if (!isValidNonce) {
|
|
242
|
+
throw new Error('Nonce has already been used or expired');
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
return validated;
|
|
246
|
+
} catch (error) {
|
|
247
|
+
throw new Error(`[LTI] Launch verification failed: ${formatError(error)}`);
|
|
251
248
|
}
|
|
252
|
-
|
|
253
|
-
return validated;
|
|
254
249
|
}
|
|
255
250
|
|
|
256
251
|
/**
|
|
@@ -259,17 +254,21 @@ export class LTITool {
|
|
|
259
254
|
* @returns JWKS object with the tool's public key for JWT signature verification
|
|
260
255
|
*/
|
|
261
256
|
async getJWKS(): Promise<JWKS> {
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
257
|
+
try {
|
|
258
|
+
const publicJwk = await exportJWK(this.config.keyPair.publicKey);
|
|
259
|
+
return {
|
|
260
|
+
keys: [
|
|
261
|
+
{
|
|
262
|
+
...publicJwk,
|
|
263
|
+
use: 'sig',
|
|
264
|
+
alg: 'RS256',
|
|
265
|
+
kid: this.config.security?.keyId ?? 'main',
|
|
266
|
+
},
|
|
267
|
+
],
|
|
268
|
+
};
|
|
269
|
+
} catch (error) {
|
|
270
|
+
throw new Error(`[LTI] JWKS generation failed: ${formatError(error)}`);
|
|
271
|
+
}
|
|
273
272
|
}
|
|
274
273
|
|
|
275
274
|
/**
|
|
@@ -279,9 +278,15 @@ export class LTITool {
|
|
|
279
278
|
* @returns Created session object with user, context, and service information
|
|
280
279
|
*/
|
|
281
280
|
async createSession(lti13JwtPayload: LTI13JwtPayload): Promise<LTISession> {
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
281
|
+
try {
|
|
282
|
+
const session = createSession(lti13JwtPayload);
|
|
283
|
+
await this.config.storage.addSession(session);
|
|
284
|
+
return session;
|
|
285
|
+
} catch (error) {
|
|
286
|
+
throw new Error(
|
|
287
|
+
`[Session] Creation failed for user '${lti13JwtPayload.sub}': ${formatError(error)}`,
|
|
288
|
+
);
|
|
289
|
+
}
|
|
285
290
|
}
|
|
286
291
|
|
|
287
292
|
/**
|
|
@@ -291,8 +296,14 @@ export class LTITool {
|
|
|
291
296
|
* @returns Session object if found, undefined otherwise
|
|
292
297
|
*/
|
|
293
298
|
async getSession(sessionId: string): Promise<LTISession | undefined> {
|
|
294
|
-
|
|
295
|
-
|
|
299
|
+
try {
|
|
300
|
+
const validatedSessionId = SessionIdSchema.parse(sessionId);
|
|
301
|
+
return await this.config.storage.getSession(validatedSessionId);
|
|
302
|
+
} catch (error) {
|
|
303
|
+
throw new Error(
|
|
304
|
+
`[Session] Retrieval failed for ID '${sessionId}': ${formatError(error)}`,
|
|
305
|
+
);
|
|
306
|
+
}
|
|
296
307
|
}
|
|
297
308
|
|
|
298
309
|
/**
|
|
@@ -310,7 +321,13 @@ export class LTITool {
|
|
|
310
321
|
throw new Error('score is required');
|
|
311
322
|
}
|
|
312
323
|
|
|
313
|
-
|
|
324
|
+
try {
|
|
325
|
+
await this.agsService.submitScore(session, score);
|
|
326
|
+
} catch (error) {
|
|
327
|
+
throw new Error(
|
|
328
|
+
`[AGS] Score submission failed for user '${score.userId}': ${formatError(error)}`,
|
|
329
|
+
);
|
|
330
|
+
}
|
|
314
331
|
}
|
|
315
332
|
|
|
316
333
|
/**
|
|
@@ -331,9 +348,15 @@ export class LTITool {
|
|
|
331
348
|
throw new Error('session is required');
|
|
332
349
|
}
|
|
333
350
|
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
351
|
+
try {
|
|
352
|
+
const response = await this.agsService.getScores(session);
|
|
353
|
+
const data = await response.json();
|
|
354
|
+
return ResultsSchema.parse(data);
|
|
355
|
+
} catch (error) {
|
|
356
|
+
throw new Error(
|
|
357
|
+
`[AGS] Scores retrieval failed for session '${session.id}': ${formatError(error)}`,
|
|
358
|
+
);
|
|
359
|
+
}
|
|
337
360
|
}
|
|
338
361
|
|
|
339
362
|
/**
|
|
@@ -348,9 +371,15 @@ export class LTITool {
|
|
|
348
371
|
throw new Error('session is required');
|
|
349
372
|
}
|
|
350
373
|
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
374
|
+
try {
|
|
375
|
+
const response = await this.agsService.listLineItems(session);
|
|
376
|
+
const data = await response.json();
|
|
377
|
+
return LineItemsSchema.parse(data);
|
|
378
|
+
} catch (error) {
|
|
379
|
+
throw new Error(
|
|
380
|
+
`[AGS] Line items listing failed for session '${session.id}': ${formatError(error)}`,
|
|
381
|
+
);
|
|
382
|
+
}
|
|
354
383
|
}
|
|
355
384
|
|
|
356
385
|
/**
|
|
@@ -365,9 +394,15 @@ export class LTITool {
|
|
|
365
394
|
throw new Error('session is required');
|
|
366
395
|
}
|
|
367
396
|
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
397
|
+
try {
|
|
398
|
+
const response = await this.agsService.getLineItem(session);
|
|
399
|
+
const data = await response.json();
|
|
400
|
+
return LineItemSchema.parse(data);
|
|
401
|
+
} catch (error) {
|
|
402
|
+
throw new Error(
|
|
403
|
+
`[AGS] Line item retrieval failed for session '${session.id}': ${formatError(error)}`,
|
|
404
|
+
);
|
|
405
|
+
}
|
|
371
406
|
}
|
|
372
407
|
|
|
373
408
|
/**
|
|
@@ -400,9 +435,15 @@ export class LTITool {
|
|
|
400
435
|
throw new Error('createLineItem is required');
|
|
401
436
|
}
|
|
402
437
|
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
438
|
+
try {
|
|
439
|
+
const response = await this.agsService.createLineItem(session, createLineItem);
|
|
440
|
+
const data = await response.json();
|
|
441
|
+
return LineItemSchema.parse(data);
|
|
442
|
+
} catch (error) {
|
|
443
|
+
throw new Error(
|
|
444
|
+
`[AGS] Line item creation failed for '${createLineItem.label}': ${formatError(error)}`,
|
|
445
|
+
);
|
|
446
|
+
}
|
|
406
447
|
}
|
|
407
448
|
|
|
408
449
|
/**
|
|
@@ -424,9 +465,15 @@ export class LTITool {
|
|
|
424
465
|
throw new Error('lineItem is required');
|
|
425
466
|
}
|
|
426
467
|
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
468
|
+
try {
|
|
469
|
+
const response = await this.agsService.updateLineItem(session, updateLineItem);
|
|
470
|
+
const data = await response.json();
|
|
471
|
+
return LineItemSchema.parse(data);
|
|
472
|
+
} catch (error) {
|
|
473
|
+
throw new Error(
|
|
474
|
+
`[AGS] Line item update failed for '${updateLineItem.label}': ${formatError(error)}`,
|
|
475
|
+
);
|
|
476
|
+
}
|
|
430
477
|
}
|
|
431
478
|
|
|
432
479
|
/**
|
|
@@ -440,7 +487,13 @@ export class LTITool {
|
|
|
440
487
|
throw new Error('session is required');
|
|
441
488
|
}
|
|
442
489
|
|
|
443
|
-
|
|
490
|
+
try {
|
|
491
|
+
await this.agsService.deleteLineItem(session);
|
|
492
|
+
} catch (error) {
|
|
493
|
+
throw new Error(
|
|
494
|
+
`[AGS] Line item deletion failed for session '${session.id}': ${formatError(error)}`,
|
|
495
|
+
);
|
|
496
|
+
}
|
|
444
497
|
}
|
|
445
498
|
|
|
446
499
|
/**
|
|
@@ -463,23 +516,29 @@ export class LTITool {
|
|
|
463
516
|
throw new Error('session is required');
|
|
464
517
|
}
|
|
465
518
|
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
519
|
+
try {
|
|
520
|
+
const response = await this.nrpsService.getMembers(session);
|
|
521
|
+
const data = await response.json();
|
|
522
|
+
const validated = NRPSContextMembershipResponseSchema.parse(data);
|
|
523
|
+
|
|
524
|
+
// Transform to clean camelCase format
|
|
525
|
+
return validated.members.map((member) => ({
|
|
526
|
+
status: member.status,
|
|
527
|
+
name: member.name,
|
|
528
|
+
picture: member.picture,
|
|
529
|
+
givenName: member.given_name,
|
|
530
|
+
familyName: member.family_name,
|
|
531
|
+
middleName: member.middle_name,
|
|
532
|
+
email: member.email,
|
|
533
|
+
userId: member.user_id,
|
|
534
|
+
lisPersonSourcedId: member.lis_person_sourcedid,
|
|
535
|
+
roles: member.roles,
|
|
536
|
+
}));
|
|
537
|
+
} catch (error) {
|
|
538
|
+
throw new Error(
|
|
539
|
+
`[NRPS] Members retrieval failed for session '${session.id}': ${formatError(error)}`,
|
|
540
|
+
);
|
|
541
|
+
}
|
|
483
542
|
}
|
|
484
543
|
|
|
485
544
|
/**
|
|
@@ -514,7 +573,13 @@ export class LTITool {
|
|
|
514
573
|
throw new Error('contentItems is required');
|
|
515
574
|
}
|
|
516
575
|
|
|
517
|
-
|
|
576
|
+
try {
|
|
577
|
+
return await this.deepLinkingService.createResponse(session, contentItems);
|
|
578
|
+
} catch (error) {
|
|
579
|
+
throw new Error(
|
|
580
|
+
`[Deep Linking] Response creation failed for session '${session.id}': ${formatError(error)}`,
|
|
581
|
+
);
|
|
582
|
+
}
|
|
518
583
|
}
|
|
519
584
|
|
|
520
585
|
/**
|
|
@@ -540,9 +605,15 @@ export class LTITool {
|
|
|
540
605
|
if (!this.dynamicRegistrationService) {
|
|
541
606
|
throw new Error('Dynamic registration service is not configured');
|
|
542
607
|
}
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
608
|
+
try {
|
|
609
|
+
return await this.dynamicRegistrationService.fetchPlatformConfiguration(
|
|
610
|
+
registrationRequest,
|
|
611
|
+
);
|
|
612
|
+
} catch (error) {
|
|
613
|
+
throw new Error(
|
|
614
|
+
`[Dynamic Registration] Platform configuration fetch failed: ${formatError(error)}`,
|
|
615
|
+
);
|
|
616
|
+
}
|
|
546
617
|
}
|
|
547
618
|
|
|
548
619
|
/**
|
|
@@ -561,10 +632,14 @@ export class LTITool {
|
|
|
561
632
|
if (!this.dynamicRegistrationService) {
|
|
562
633
|
throw new Error('Dynamic registration service is not configured');
|
|
563
634
|
}
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
635
|
+
try {
|
|
636
|
+
return await this.dynamicRegistrationService.initiateDynamicRegistration(
|
|
637
|
+
registrationRequest,
|
|
638
|
+
requestPath,
|
|
639
|
+
);
|
|
640
|
+
} catch (error) {
|
|
641
|
+
throw new Error(`[Dynamic Registration] Initiation failed: ${formatError(error)}`);
|
|
642
|
+
}
|
|
568
643
|
}
|
|
569
644
|
|
|
570
645
|
/**
|
|
@@ -582,9 +657,13 @@ export class LTITool {
|
|
|
582
657
|
throw new Error('Dynamic registration service is not configured');
|
|
583
658
|
}
|
|
584
659
|
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
660
|
+
try {
|
|
661
|
+
return await this.dynamicRegistrationService.completeDynamicRegistration(
|
|
662
|
+
dynamicRegistrationForm,
|
|
663
|
+
);
|
|
664
|
+
} catch (error) {
|
|
665
|
+
throw new Error(`[Dynamic Registration] Completion failed: ${formatError(error)}`);
|
|
666
|
+
}
|
|
588
667
|
}
|
|
589
668
|
|
|
590
669
|
// Client management
|
|
@@ -595,7 +674,11 @@ export class LTITool {
|
|
|
595
674
|
* @returns Array of client configurations (without deployment details)
|
|
596
675
|
*/
|
|
597
676
|
async listClients(): Promise<Omit<LTIClient, 'deployments'>[]> {
|
|
598
|
-
|
|
677
|
+
try {
|
|
678
|
+
return await this.config.storage.listClients();
|
|
679
|
+
} catch (error) {
|
|
680
|
+
throw new Error(`[Client] Listing failed: ${formatError(error)}`);
|
|
681
|
+
}
|
|
599
682
|
}
|
|
600
683
|
|
|
601
684
|
/**
|
|
@@ -608,8 +691,14 @@ export class LTITool {
|
|
|
608
691
|
clientId: string,
|
|
609
692
|
client: Partial<Omit<LTIClient, 'id' | 'deployments'>>,
|
|
610
693
|
): Promise<void> {
|
|
611
|
-
|
|
612
|
-
|
|
694
|
+
try {
|
|
695
|
+
const validated = UpdateClientSchema.parse(client);
|
|
696
|
+
return await this.config.storage.updateClient(clientId, validated);
|
|
697
|
+
} catch (error) {
|
|
698
|
+
throw new Error(
|
|
699
|
+
`[Client] Update failed for ID '${clientId}': ${formatError(error)}`,
|
|
700
|
+
);
|
|
701
|
+
}
|
|
613
702
|
}
|
|
614
703
|
|
|
615
704
|
/**
|
|
@@ -619,7 +708,13 @@ export class LTITool {
|
|
|
619
708
|
* @returns Client configuration if found, undefined otherwise
|
|
620
709
|
*/
|
|
621
710
|
async getClientById(clientId: string): Promise<LTIClient | undefined> {
|
|
622
|
-
|
|
711
|
+
try {
|
|
712
|
+
return await this.config.storage.getClientById(clientId);
|
|
713
|
+
} catch (error) {
|
|
714
|
+
throw new Error(
|
|
715
|
+
`[Client] Retrieval failed for ID '${clientId}': ${formatError(error)}`,
|
|
716
|
+
);
|
|
717
|
+
}
|
|
623
718
|
}
|
|
624
719
|
|
|
625
720
|
/**
|
|
@@ -629,8 +724,14 @@ export class LTITool {
|
|
|
629
724
|
* @returns The generated client ID
|
|
630
725
|
*/
|
|
631
726
|
async addClient(client: Omit<LTIClient, 'id' | 'deployments'>): Promise<string> {
|
|
632
|
-
|
|
633
|
-
|
|
727
|
+
try {
|
|
728
|
+
const validated = AddClientSchema.parse(client);
|
|
729
|
+
return await this.config.storage.addClient(validated);
|
|
730
|
+
} catch (error) {
|
|
731
|
+
throw new Error(
|
|
732
|
+
`[Client] Creation failed for issuer '${client.iss}': ${formatError(error)}`,
|
|
733
|
+
);
|
|
734
|
+
}
|
|
634
735
|
}
|
|
635
736
|
|
|
636
737
|
/**
|
|
@@ -639,7 +740,13 @@ export class LTITool {
|
|
|
639
740
|
* @param clientId - Unique client identifier
|
|
640
741
|
*/
|
|
641
742
|
async deleteClient(clientId: string): Promise<void> {
|
|
642
|
-
|
|
743
|
+
try {
|
|
744
|
+
return await this.config.storage.deleteClient(clientId);
|
|
745
|
+
} catch (error) {
|
|
746
|
+
throw new Error(
|
|
747
|
+
`[Client] Deletion failed for ID '${clientId}': ${formatError(error)}`,
|
|
748
|
+
);
|
|
749
|
+
}
|
|
643
750
|
}
|
|
644
751
|
|
|
645
752
|
// Deployment management
|
|
@@ -651,7 +758,13 @@ export class LTITool {
|
|
|
651
758
|
* @returns Array of deployment configurations for the client
|
|
652
759
|
*/
|
|
653
760
|
async listDeployments(clientId: string): Promise<LTIDeployment[]> {
|
|
654
|
-
|
|
761
|
+
try {
|
|
762
|
+
return await this.config.storage.listDeployments(clientId);
|
|
763
|
+
} catch (error) {
|
|
764
|
+
throw new Error(
|
|
765
|
+
`[Deployment] Listing failed for client '${clientId}': ${formatError(error)}`,
|
|
766
|
+
);
|
|
767
|
+
}
|
|
655
768
|
}
|
|
656
769
|
|
|
657
770
|
/**
|
|
@@ -665,7 +778,13 @@ export class LTITool {
|
|
|
665
778
|
clientId: string,
|
|
666
779
|
deploymentId: string,
|
|
667
780
|
): Promise<LTIDeployment | undefined> {
|
|
668
|
-
|
|
781
|
+
try {
|
|
782
|
+
return await this.config.storage.getDeployment(clientId, deploymentId);
|
|
783
|
+
} catch (error) {
|
|
784
|
+
throw new Error(
|
|
785
|
+
`[Deployment] Retrieval failed for client '${clientId}', deployment '${deploymentId}': ${formatError(error)}`,
|
|
786
|
+
);
|
|
787
|
+
}
|
|
669
788
|
}
|
|
670
789
|
|
|
671
790
|
/**
|
|
@@ -679,7 +798,13 @@ export class LTITool {
|
|
|
679
798
|
clientId: string,
|
|
680
799
|
deployment: Omit<LTIDeployment, 'id'>,
|
|
681
800
|
): Promise<string> {
|
|
682
|
-
|
|
801
|
+
try {
|
|
802
|
+
return await this.config.storage.addDeployment(clientId, deployment);
|
|
803
|
+
} catch (error) {
|
|
804
|
+
throw new Error(
|
|
805
|
+
`[Deployment] Creation failed for client '${clientId}': ${formatError(error)}`,
|
|
806
|
+
);
|
|
807
|
+
}
|
|
683
808
|
}
|
|
684
809
|
|
|
685
810
|
/**
|
|
@@ -694,7 +819,17 @@ export class LTITool {
|
|
|
694
819
|
deploymentId: string,
|
|
695
820
|
deployment: Partial<LTIDeployment>,
|
|
696
821
|
): Promise<void> {
|
|
697
|
-
|
|
822
|
+
try {
|
|
823
|
+
return await this.config.storage.updateDeployment(
|
|
824
|
+
clientId,
|
|
825
|
+
deploymentId,
|
|
826
|
+
deployment,
|
|
827
|
+
);
|
|
828
|
+
} catch (error) {
|
|
829
|
+
throw new Error(
|
|
830
|
+
`Deployment update failed for client '${clientId}' and deployment '${deploymentId}': ${formatError(error)}`,
|
|
831
|
+
);
|
|
832
|
+
}
|
|
698
833
|
}
|
|
699
834
|
|
|
700
835
|
/**
|
|
@@ -704,7 +839,13 @@ export class LTITool {
|
|
|
704
839
|
* @param deploymentId - Deployment identifier to remove
|
|
705
840
|
*/
|
|
706
841
|
async deleteDeployment(clientId: string, deploymentId: string): Promise<void> {
|
|
707
|
-
|
|
842
|
+
try {
|
|
843
|
+
return await this.config.storage.deleteDeployment(clientId, deploymentId);
|
|
844
|
+
} catch (error) {
|
|
845
|
+
throw new Error(
|
|
846
|
+
`[Deployment] Deletion failed for client '${clientId}', deployment '${deploymentId}': ${formatError(error)}`,
|
|
847
|
+
);
|
|
848
|
+
}
|
|
708
849
|
}
|
|
709
850
|
|
|
710
851
|
// Dynamic Registration Session Management
|
|
@@ -720,7 +861,13 @@ export class LTITool {
|
|
|
720
861
|
sessionId: string,
|
|
721
862
|
session: LTIDynamicRegistrationSession,
|
|
722
863
|
): Promise<void> {
|
|
723
|
-
|
|
864
|
+
try {
|
|
865
|
+
return await this.config.storage.setRegistrationSession(sessionId, session);
|
|
866
|
+
} catch (error) {
|
|
867
|
+
throw new Error(
|
|
868
|
+
`[Dynamic Registration] Session storage failed for ID '${sessionId}': ${formatError(error)}`,
|
|
869
|
+
);
|
|
870
|
+
}
|
|
724
871
|
}
|
|
725
872
|
|
|
726
873
|
/**
|
|
@@ -733,7 +880,13 @@ export class LTITool {
|
|
|
733
880
|
async getRegistrationSession(
|
|
734
881
|
sessionId: string,
|
|
735
882
|
): Promise<LTIDynamicRegistrationSession | undefined> {
|
|
736
|
-
|
|
883
|
+
try {
|
|
884
|
+
return await this.config.storage.getRegistrationSession(sessionId);
|
|
885
|
+
} catch (error) {
|
|
886
|
+
throw new Error(
|
|
887
|
+
`[Dynamic Registration] Session retrieval failed for ID '${sessionId}': ${formatError(error)}`,
|
|
888
|
+
);
|
|
889
|
+
}
|
|
737
890
|
}
|
|
738
891
|
|
|
739
892
|
/**
|
|
@@ -743,6 +896,52 @@ export class LTITool {
|
|
|
743
896
|
* @param sessionId - Unique session identifier to delete
|
|
744
897
|
*/
|
|
745
898
|
async deleteRegistrationSession(sessionId: string): Promise<void> {
|
|
746
|
-
|
|
899
|
+
try {
|
|
900
|
+
return await this.config.storage.deleteRegistrationSession(sessionId);
|
|
901
|
+
} catch (error) {
|
|
902
|
+
throw new Error(
|
|
903
|
+
`[Dynamic Registration] Session deletion failed for ID '${sessionId}': ${formatError(error)}`,
|
|
904
|
+
);
|
|
905
|
+
}
|
|
906
|
+
}
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
/**
|
|
910
|
+
* Builds the authorization URL for LTI 1.3 OIDC authentication flow.
|
|
911
|
+
*
|
|
912
|
+
* @param launchConfig - Launch configuration containing auth endpoints
|
|
913
|
+
* @param validatedParams - Validated login parameters
|
|
914
|
+
* @param state - State JWT for CSRF protection
|
|
915
|
+
* @param nonce - Nonce for replay attack prevention
|
|
916
|
+
* @returns Complete authorization URL with all required parameters
|
|
917
|
+
*/
|
|
918
|
+
function buildAuthUrl(
|
|
919
|
+
launchConfig: { authUrl: string },
|
|
920
|
+
validatedParams: {
|
|
921
|
+
client_id: string;
|
|
922
|
+
launchUrl: URL | string;
|
|
923
|
+
login_hint: string;
|
|
924
|
+
lti_deployment_id: string;
|
|
925
|
+
lti_message_hint?: string;
|
|
926
|
+
},
|
|
927
|
+
state: string,
|
|
928
|
+
nonce: string,
|
|
929
|
+
): string {
|
|
930
|
+
const authUrl = new URL(launchConfig.authUrl);
|
|
931
|
+
authUrl.searchParams.set('scope', 'openid');
|
|
932
|
+
authUrl.searchParams.set('response_type', 'id_token');
|
|
933
|
+
authUrl.searchParams.set('response_mode', 'form_post');
|
|
934
|
+
authUrl.searchParams.set('prompt', 'none');
|
|
935
|
+
authUrl.searchParams.set('client_id', validatedParams.client_id);
|
|
936
|
+
authUrl.searchParams.set('redirect_uri', validatedParams.launchUrl.toString());
|
|
937
|
+
authUrl.searchParams.set('login_hint', validatedParams.login_hint);
|
|
938
|
+
authUrl.searchParams.set('state', state);
|
|
939
|
+
authUrl.searchParams.set('nonce', nonce);
|
|
940
|
+
authUrl.searchParams.set('lti_deployment_id', validatedParams.lti_deployment_id);
|
|
941
|
+
|
|
942
|
+
if (validatedParams.lti_message_hint) {
|
|
943
|
+
authUrl.searchParams.set('lti_message_hint', validatedParams.lti_message_hint);
|
|
747
944
|
}
|
|
945
|
+
|
|
946
|
+
return authUrl.toString();
|
|
748
947
|
}
|