@lti-tool/core 0.13.0 → 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 +12 -0
- package/dist/ltiTool.d.ts.map +1 -1
- package/dist/ltiTool.js +297 -134
- package/dist/services/ags.service.d.ts +23 -0
- package/dist/services/ags.service.d.ts.map +1 -1
- package/dist/services/ags.service.js +23 -0
- package/dist/services/deepLinking.service.d.ts +9 -4
- package/dist/services/deepLinking.service.d.ts.map +1 -1
- package/dist/services/deepLinking.service.js +9 -4
- package/dist/services/dynamicRegistration.service.d.ts +7 -0
- package/dist/services/dynamicRegistration.service.d.ts.map +1 -1
- package/dist/services/dynamicRegistration.service.js +7 -0
- package/dist/services/nrps.service.d.ts +15 -1
- package/dist/services/nrps.service.d.ts.map +1 -1
- package/dist/services/nrps.service.js +15 -1
- package/dist/services/token.service.d.ts +5 -2
- package/dist/services/token.service.d.ts.map +1 -1
- package/dist/services/token.service.js +5 -2
- 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 +365 -166
- package/src/services/ags.service.ts +23 -0
- package/src/services/deepLinking.service.ts +9 -4
- package/src/services/dynamicRegistration.service.ts +7 -0
- package/src/services/nrps.service.ts +15 -1
- package/src/services/token.service.ts +5 -2
- package/src/utils/errorFormatting.ts +22 -0
package/dist/ltiTool.js
CHANGED
|
@@ -10,6 +10,7 @@ import { DynamicRegistrationService } from './services/dynamicRegistration.servi
|
|
|
10
10
|
import { NRPSService } from './services/nrps.service.js';
|
|
11
11
|
import { createSession } from './services/session.service.js';
|
|
12
12
|
import { TokenService } from './services/token.service.js';
|
|
13
|
+
import { formatError } from './utils/errorFormatting.js';
|
|
13
14
|
import { getValidLaunchConfig } from './utils/launchConfigValidation.js';
|
|
14
15
|
/**
|
|
15
16
|
* Main LTI 1.3 Tool implementation providing secure authentication, launch verification,
|
|
@@ -82,38 +83,29 @@ export class LTITool {
|
|
|
82
83
|
* @throws {Error} When platform configuration is not found
|
|
83
84
|
*/
|
|
84
85
|
async handleLogin(params) {
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
(
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
authUrl.searchParams.set('redirect_uri', validatedParams.launchUrl.toString());
|
|
109
|
-
authUrl.searchParams.set('login_hint', validatedParams.login_hint);
|
|
110
|
-
authUrl.searchParams.set('state', state);
|
|
111
|
-
authUrl.searchParams.set('nonce', nonce);
|
|
112
|
-
authUrl.searchParams.set('lti_deployment_id', validatedParams.lti_deployment_id);
|
|
113
|
-
if (validatedParams.lti_message_hint) {
|
|
114
|
-
authUrl.searchParams.set('lti_message_hint', validatedParams.lti_message_hint);
|
|
115
|
-
}
|
|
116
|
-
return authUrl.toString();
|
|
86
|
+
try {
|
|
87
|
+
const validatedParams = HandleLoginParamsSchema.parse(params);
|
|
88
|
+
const nonce = crypto.randomUUID();
|
|
89
|
+
// Store nonce with expiration for replay attack prevention
|
|
90
|
+
const nonceExpirationSeconds = this.config.security?.nonceExpirationSeconds ?? 600;
|
|
91
|
+
const nonceExpiresAt = new Date(Date.now() + nonceExpirationSeconds * 1000);
|
|
92
|
+
await this.config.storage.storeNonce(nonce, nonceExpiresAt);
|
|
93
|
+
const state = await new SignJWT({
|
|
94
|
+
nonce,
|
|
95
|
+
iss: validatedParams.iss,
|
|
96
|
+
client_id: validatedParams.client_id,
|
|
97
|
+
target_link_uri: validatedParams.target_link_uri,
|
|
98
|
+
exp: Math.floor(Date.now() / 1000) +
|
|
99
|
+
(this.config.security?.stateExpirationSeconds ?? 600),
|
|
100
|
+
})
|
|
101
|
+
.setProtectedHeader({ alg: 'HS256' })
|
|
102
|
+
.sign(this.config.stateSecret);
|
|
103
|
+
const launchConfig = await getValidLaunchConfig(this.config.storage, validatedParams.iss, validatedParams.client_id, validatedParams.lti_deployment_id);
|
|
104
|
+
return buildAuthUrl(launchConfig, validatedParams, state, nonce);
|
|
105
|
+
}
|
|
106
|
+
catch (error) {
|
|
107
|
+
throw new Error(`[LTI] Login initiation failed for issuer '${params.iss}', client '${params.client_id}': ${formatError(error)}`);
|
|
108
|
+
}
|
|
117
109
|
}
|
|
118
110
|
/**
|
|
119
111
|
* Verifies and validates an LTI 1.3 launch by checking JWT signatures, nonces, and claims.
|
|
@@ -131,39 +123,44 @@ export class LTITool {
|
|
|
131
123
|
* @throws {Error} When verification fails for security reasons
|
|
132
124
|
*/
|
|
133
125
|
async verifyLaunch(idToken, state) {
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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
|
-
|
|
126
|
+
try {
|
|
127
|
+
const validatedParams = VerifyLaunchParamsSchema.parse({ idToken, state });
|
|
128
|
+
// 1. UNVERIFIED - get issuer
|
|
129
|
+
const unverified = decodeJwt(validatedParams.idToken);
|
|
130
|
+
if (!unverified.iss) {
|
|
131
|
+
throw new Error('No issuer in token');
|
|
132
|
+
}
|
|
133
|
+
// 2. get the launchConfig so we can get the remote JWKS from our data store
|
|
134
|
+
const launchConfig = await getValidLaunchConfig(this.config.storage, unverified.iss, unverified.aud, unverified['https://purl.imsglobal.org/spec/lti/claim/deployment_id']);
|
|
135
|
+
// 3. Verify LMS JWT
|
|
136
|
+
let jwks = this.jwksCache.get(launchConfig.jwksUrl);
|
|
137
|
+
if (!jwks) {
|
|
138
|
+
jwks = createRemoteJWKSet(new URL(launchConfig.jwksUrl));
|
|
139
|
+
this.jwksCache.set(launchConfig.jwksUrl, jwks);
|
|
140
|
+
}
|
|
141
|
+
const { payload } = await jwtVerify(validatedParams.idToken, jwks);
|
|
142
|
+
// 4. Verify our state JWT
|
|
143
|
+
const { payload: stateData } = await jwtVerify(validatedParams.state, this.config.stateSecret);
|
|
144
|
+
// 5. Parse and validate LMS JWT
|
|
145
|
+
const validated = LTI13JwtPayloadSchema.parse(payload);
|
|
146
|
+
// 6. Verify client id matches (audience claim)
|
|
147
|
+
if (validated.aud !== launchConfig.clientId) {
|
|
148
|
+
throw new Error(`Invalid client_id: expected ${launchConfig.clientId}, got ${validated.aud}`);
|
|
149
|
+
}
|
|
150
|
+
// 7. Verify nonce matches
|
|
151
|
+
if (stateData.nonce !== validated.nonce) {
|
|
152
|
+
throw new Error('Nonce mismatch');
|
|
153
|
+
}
|
|
154
|
+
// 8. Check nonce hasn't been used before (prevent replay attacks)
|
|
155
|
+
const isValidNonce = await this.config.storage.validateNonce(validated.nonce);
|
|
156
|
+
if (!isValidNonce) {
|
|
157
|
+
throw new Error('Nonce has already been used or expired');
|
|
158
|
+
}
|
|
159
|
+
return validated;
|
|
160
|
+
}
|
|
161
|
+
catch (error) {
|
|
162
|
+
throw new Error(`[LTI] Launch verification failed: ${formatError(error)}`);
|
|
163
|
+
}
|
|
167
164
|
}
|
|
168
165
|
/**
|
|
169
166
|
* Generates JSON Web Key Set (JWKS) containing the tool's public key for platform verification.
|
|
@@ -171,17 +168,22 @@ export class LTITool {
|
|
|
171
168
|
* @returns JWKS object with the tool's public key for JWT signature verification
|
|
172
169
|
*/
|
|
173
170
|
async getJWKS() {
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
171
|
+
try {
|
|
172
|
+
const publicJwk = await exportJWK(this.config.keyPair.publicKey);
|
|
173
|
+
return {
|
|
174
|
+
keys: [
|
|
175
|
+
{
|
|
176
|
+
...publicJwk,
|
|
177
|
+
use: 'sig',
|
|
178
|
+
alg: 'RS256',
|
|
179
|
+
kid: this.config.security?.keyId ?? 'main',
|
|
180
|
+
},
|
|
181
|
+
],
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
catch (error) {
|
|
185
|
+
throw new Error(`[LTI] JWKS generation failed: ${formatError(error)}`);
|
|
186
|
+
}
|
|
185
187
|
}
|
|
186
188
|
/**
|
|
187
189
|
* Creates and stores a new LTI session from validated JWT payload.
|
|
@@ -190,9 +192,14 @@ export class LTITool {
|
|
|
190
192
|
* @returns Created session object with user, context, and service information
|
|
191
193
|
*/
|
|
192
194
|
async createSession(lti13JwtPayload) {
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
195
|
+
try {
|
|
196
|
+
const session = createSession(lti13JwtPayload);
|
|
197
|
+
await this.config.storage.addSession(session);
|
|
198
|
+
return session;
|
|
199
|
+
}
|
|
200
|
+
catch (error) {
|
|
201
|
+
throw new Error(`[Session] Creation failed for user '${lti13JwtPayload.sub}': ${formatError(error)}`);
|
|
202
|
+
}
|
|
196
203
|
}
|
|
197
204
|
/**
|
|
198
205
|
* Retrieves an existing LTI session by session ID.
|
|
@@ -201,8 +208,13 @@ export class LTITool {
|
|
|
201
208
|
* @returns Session object if found, undefined otherwise
|
|
202
209
|
*/
|
|
203
210
|
async getSession(sessionId) {
|
|
204
|
-
|
|
205
|
-
|
|
211
|
+
try {
|
|
212
|
+
const validatedSessionId = SessionIdSchema.parse(sessionId);
|
|
213
|
+
return await this.config.storage.getSession(validatedSessionId);
|
|
214
|
+
}
|
|
215
|
+
catch (error) {
|
|
216
|
+
throw new Error(`[Session] Retrieval failed for ID '${sessionId}': ${formatError(error)}`);
|
|
217
|
+
}
|
|
206
218
|
}
|
|
207
219
|
/**
|
|
208
220
|
* Submits a grade score to the platform using Assignment and Grade Services (AGS).
|
|
@@ -218,7 +230,12 @@ export class LTITool {
|
|
|
218
230
|
if (!score) {
|
|
219
231
|
throw new Error('score is required');
|
|
220
232
|
}
|
|
221
|
-
|
|
233
|
+
try {
|
|
234
|
+
await this.agsService.submitScore(session, score);
|
|
235
|
+
}
|
|
236
|
+
catch (error) {
|
|
237
|
+
throw new Error(`[AGS] Score submission failed for user '${score.userId}': ${formatError(error)}`);
|
|
238
|
+
}
|
|
222
239
|
}
|
|
223
240
|
/**
|
|
224
241
|
* Retrieves all scores for a specific line item from the platform using Assignment and Grade Services (AGS).
|
|
@@ -237,9 +254,14 @@ export class LTITool {
|
|
|
237
254
|
if (!session) {
|
|
238
255
|
throw new Error('session is required');
|
|
239
256
|
}
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
257
|
+
try {
|
|
258
|
+
const response = await this.agsService.getScores(session);
|
|
259
|
+
const data = await response.json();
|
|
260
|
+
return ResultsSchema.parse(data);
|
|
261
|
+
}
|
|
262
|
+
catch (error) {
|
|
263
|
+
throw new Error(`[AGS] Scores retrieval failed for session '${session.id}': ${formatError(error)}`);
|
|
264
|
+
}
|
|
243
265
|
}
|
|
244
266
|
/**
|
|
245
267
|
* Retrieves line items (gradebook columns) from the platform using Assignment and Grade Services (AGS).
|
|
@@ -252,9 +274,14 @@ export class LTITool {
|
|
|
252
274
|
if (!session) {
|
|
253
275
|
throw new Error('session is required');
|
|
254
276
|
}
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
277
|
+
try {
|
|
278
|
+
const response = await this.agsService.listLineItems(session);
|
|
279
|
+
const data = await response.json();
|
|
280
|
+
return LineItemsSchema.parse(data);
|
|
281
|
+
}
|
|
282
|
+
catch (error) {
|
|
283
|
+
throw new Error(`[AGS] Line items listing failed for session '${session.id}': ${formatError(error)}`);
|
|
284
|
+
}
|
|
258
285
|
}
|
|
259
286
|
/**
|
|
260
287
|
* Retrieves a specific line item (gradebook column) from the platform using Assignment and Grade Services (AGS).
|
|
@@ -267,9 +294,14 @@ export class LTITool {
|
|
|
267
294
|
if (!session) {
|
|
268
295
|
throw new Error('session is required');
|
|
269
296
|
}
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
297
|
+
try {
|
|
298
|
+
const response = await this.agsService.getLineItem(session);
|
|
299
|
+
const data = await response.json();
|
|
300
|
+
return LineItemSchema.parse(data);
|
|
301
|
+
}
|
|
302
|
+
catch (error) {
|
|
303
|
+
throw new Error(`[AGS] Line item retrieval failed for session '${session.id}': ${formatError(error)}`);
|
|
304
|
+
}
|
|
273
305
|
}
|
|
274
306
|
/**
|
|
275
307
|
* Creates a new line item (gradebook column) on the platform using Assignment and Grade Services (AGS).
|
|
@@ -297,9 +329,14 @@ export class LTITool {
|
|
|
297
329
|
if (!createLineItem) {
|
|
298
330
|
throw new Error('createLineItem is required');
|
|
299
331
|
}
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
332
|
+
try {
|
|
333
|
+
const response = await this.agsService.createLineItem(session, createLineItem);
|
|
334
|
+
const data = await response.json();
|
|
335
|
+
return LineItemSchema.parse(data);
|
|
336
|
+
}
|
|
337
|
+
catch (error) {
|
|
338
|
+
throw new Error(`[AGS] Line item creation failed for '${createLineItem.label}': ${formatError(error)}`);
|
|
339
|
+
}
|
|
303
340
|
}
|
|
304
341
|
/**
|
|
305
342
|
* Updates an existing line item (gradebook column) on the platform using Assignment and Grade Services (AGS).
|
|
@@ -316,9 +353,14 @@ export class LTITool {
|
|
|
316
353
|
if (!updateLineItem) {
|
|
317
354
|
throw new Error('lineItem is required');
|
|
318
355
|
}
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
356
|
+
try {
|
|
357
|
+
const response = await this.agsService.updateLineItem(session, updateLineItem);
|
|
358
|
+
const data = await response.json();
|
|
359
|
+
return LineItemSchema.parse(data);
|
|
360
|
+
}
|
|
361
|
+
catch (error) {
|
|
362
|
+
throw new Error(`[AGS] Line item update failed for '${updateLineItem.label}': ${formatError(error)}`);
|
|
363
|
+
}
|
|
322
364
|
}
|
|
323
365
|
/**
|
|
324
366
|
* Deletes a line item (gradebook column) from the platform using Assignment and Grade Services (AGS).
|
|
@@ -330,7 +372,12 @@ export class LTITool {
|
|
|
330
372
|
if (!session) {
|
|
331
373
|
throw new Error('session is required');
|
|
332
374
|
}
|
|
333
|
-
|
|
375
|
+
try {
|
|
376
|
+
await this.agsService.deleteLineItem(session);
|
|
377
|
+
}
|
|
378
|
+
catch (error) {
|
|
379
|
+
throw new Error(`[AGS] Line item deletion failed for session '${session.id}': ${formatError(error)}`);
|
|
380
|
+
}
|
|
334
381
|
}
|
|
335
382
|
/**
|
|
336
383
|
* Retrieves course/context members using Names and Role Provisioning Services (NRPS).
|
|
@@ -351,22 +398,27 @@ export class LTITool {
|
|
|
351
398
|
if (!session) {
|
|
352
399
|
throw new Error('session is required');
|
|
353
400
|
}
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
401
|
+
try {
|
|
402
|
+
const response = await this.nrpsService.getMembers(session);
|
|
403
|
+
const data = await response.json();
|
|
404
|
+
const validated = NRPSContextMembershipResponseSchema.parse(data);
|
|
405
|
+
// Transform to clean camelCase format
|
|
406
|
+
return validated.members.map((member) => ({
|
|
407
|
+
status: member.status,
|
|
408
|
+
name: member.name,
|
|
409
|
+
picture: member.picture,
|
|
410
|
+
givenName: member.given_name,
|
|
411
|
+
familyName: member.family_name,
|
|
412
|
+
middleName: member.middle_name,
|
|
413
|
+
email: member.email,
|
|
414
|
+
userId: member.user_id,
|
|
415
|
+
lisPersonSourcedId: member.lis_person_sourcedid,
|
|
416
|
+
roles: member.roles,
|
|
417
|
+
}));
|
|
418
|
+
}
|
|
419
|
+
catch (error) {
|
|
420
|
+
throw new Error(`[NRPS] Members retrieval failed for session '${session.id}': ${formatError(error)}`);
|
|
421
|
+
}
|
|
370
422
|
}
|
|
371
423
|
/**
|
|
372
424
|
* Creates a Deep Linking response with selected content items.
|
|
@@ -396,7 +448,12 @@ export class LTITool {
|
|
|
396
448
|
if (!contentItems) {
|
|
397
449
|
throw new Error('contentItems is required');
|
|
398
450
|
}
|
|
399
|
-
|
|
451
|
+
try {
|
|
452
|
+
return await this.deepLinkingService.createResponse(session, contentItems);
|
|
453
|
+
}
|
|
454
|
+
catch (error) {
|
|
455
|
+
throw new Error(`[Deep Linking] Response creation failed for session '${session.id}': ${formatError(error)}`);
|
|
456
|
+
}
|
|
400
457
|
}
|
|
401
458
|
/**
|
|
402
459
|
* Fetches and validates the OpenID Connect configuration from an LTI platform during dynamic registration.
|
|
@@ -419,7 +476,12 @@ export class LTITool {
|
|
|
419
476
|
if (!this.dynamicRegistrationService) {
|
|
420
477
|
throw new Error('Dynamic registration service is not configured');
|
|
421
478
|
}
|
|
422
|
-
|
|
479
|
+
try {
|
|
480
|
+
return await this.dynamicRegistrationService.fetchPlatformConfiguration(registrationRequest);
|
|
481
|
+
}
|
|
482
|
+
catch (error) {
|
|
483
|
+
throw new Error(`[Dynamic Registration] Platform configuration fetch failed: ${formatError(error)}`);
|
|
484
|
+
}
|
|
423
485
|
}
|
|
424
486
|
/**
|
|
425
487
|
* Initiates LTI 1.3 dynamic registration by fetching platform configuration and generating registration form.
|
|
@@ -434,7 +496,12 @@ export class LTITool {
|
|
|
434
496
|
if (!this.dynamicRegistrationService) {
|
|
435
497
|
throw new Error('Dynamic registration service is not configured');
|
|
436
498
|
}
|
|
437
|
-
|
|
499
|
+
try {
|
|
500
|
+
return await this.dynamicRegistrationService.initiateDynamicRegistration(registrationRequest, requestPath);
|
|
501
|
+
}
|
|
502
|
+
catch (error) {
|
|
503
|
+
throw new Error(`[Dynamic Registration] Initiation failed: ${formatError(error)}`);
|
|
504
|
+
}
|
|
438
505
|
}
|
|
439
506
|
/**
|
|
440
507
|
* Completes LTI 1.3 dynamic registration by processing form submission and storing client configuration.
|
|
@@ -446,9 +513,14 @@ export class LTITool {
|
|
|
446
513
|
*/
|
|
447
514
|
async completeDynamicRegistration(dynamicRegistrationForm) {
|
|
448
515
|
if (!this.dynamicRegistrationService) {
|
|
449
|
-
throw new Error('
|
|
516
|
+
throw new Error('Dynamic registration service is not configured');
|
|
517
|
+
}
|
|
518
|
+
try {
|
|
519
|
+
return await this.dynamicRegistrationService.completeDynamicRegistration(dynamicRegistrationForm);
|
|
520
|
+
}
|
|
521
|
+
catch (error) {
|
|
522
|
+
throw new Error(`[Dynamic Registration] Completion failed: ${formatError(error)}`);
|
|
450
523
|
}
|
|
451
|
-
return await this.dynamicRegistrationService.completeDynamicRegistration(dynamicRegistrationForm);
|
|
452
524
|
}
|
|
453
525
|
// Client management
|
|
454
526
|
/**
|
|
@@ -457,7 +529,12 @@ export class LTITool {
|
|
|
457
529
|
* @returns Array of client configurations (without deployment details)
|
|
458
530
|
*/
|
|
459
531
|
async listClients() {
|
|
460
|
-
|
|
532
|
+
try {
|
|
533
|
+
return await this.config.storage.listClients();
|
|
534
|
+
}
|
|
535
|
+
catch (error) {
|
|
536
|
+
throw new Error(`[Client] Listing failed: ${formatError(error)}`);
|
|
537
|
+
}
|
|
461
538
|
}
|
|
462
539
|
/**
|
|
463
540
|
* Updates an existing client configuration.
|
|
@@ -466,8 +543,13 @@ export class LTITool {
|
|
|
466
543
|
* @param client - Partial client object with fields to update
|
|
467
544
|
*/
|
|
468
545
|
async updateClient(clientId, client) {
|
|
469
|
-
|
|
470
|
-
|
|
546
|
+
try {
|
|
547
|
+
const validated = UpdateClientSchema.parse(client);
|
|
548
|
+
return await this.config.storage.updateClient(clientId, validated);
|
|
549
|
+
}
|
|
550
|
+
catch (error) {
|
|
551
|
+
throw new Error(`[Client] Update failed for ID '${clientId}': ${formatError(error)}`);
|
|
552
|
+
}
|
|
471
553
|
}
|
|
472
554
|
/**
|
|
473
555
|
* Retrieves a specific client configuration by ID.
|
|
@@ -476,7 +558,12 @@ export class LTITool {
|
|
|
476
558
|
* @returns Client configuration if found, undefined otherwise
|
|
477
559
|
*/
|
|
478
560
|
async getClientById(clientId) {
|
|
479
|
-
|
|
561
|
+
try {
|
|
562
|
+
return await this.config.storage.getClientById(clientId);
|
|
563
|
+
}
|
|
564
|
+
catch (error) {
|
|
565
|
+
throw new Error(`[Client] Retrieval failed for ID '${clientId}': ${formatError(error)}`);
|
|
566
|
+
}
|
|
480
567
|
}
|
|
481
568
|
/**
|
|
482
569
|
* Adds a new LTI client platform configuration.
|
|
@@ -485,8 +572,13 @@ export class LTITool {
|
|
|
485
572
|
* @returns The generated client ID
|
|
486
573
|
*/
|
|
487
574
|
async addClient(client) {
|
|
488
|
-
|
|
489
|
-
|
|
575
|
+
try {
|
|
576
|
+
const validated = AddClientSchema.parse(client);
|
|
577
|
+
return await this.config.storage.addClient(validated);
|
|
578
|
+
}
|
|
579
|
+
catch (error) {
|
|
580
|
+
throw new Error(`[Client] Creation failed for issuer '${client.iss}': ${formatError(error)}`);
|
|
581
|
+
}
|
|
490
582
|
}
|
|
491
583
|
/**
|
|
492
584
|
* Removes a client configuration and all its deployments.
|
|
@@ -494,7 +586,12 @@ export class LTITool {
|
|
|
494
586
|
* @param clientId - Unique client identifier
|
|
495
587
|
*/
|
|
496
588
|
async deleteClient(clientId) {
|
|
497
|
-
|
|
589
|
+
try {
|
|
590
|
+
return await this.config.storage.deleteClient(clientId);
|
|
591
|
+
}
|
|
592
|
+
catch (error) {
|
|
593
|
+
throw new Error(`[Client] Deletion failed for ID '${clientId}': ${formatError(error)}`);
|
|
594
|
+
}
|
|
498
595
|
}
|
|
499
596
|
// Deployment management
|
|
500
597
|
/**
|
|
@@ -504,7 +601,12 @@ export class LTITool {
|
|
|
504
601
|
* @returns Array of deployment configurations for the client
|
|
505
602
|
*/
|
|
506
603
|
async listDeployments(clientId) {
|
|
507
|
-
|
|
604
|
+
try {
|
|
605
|
+
return await this.config.storage.listDeployments(clientId);
|
|
606
|
+
}
|
|
607
|
+
catch (error) {
|
|
608
|
+
throw new Error(`[Deployment] Listing failed for client '${clientId}': ${formatError(error)}`);
|
|
609
|
+
}
|
|
508
610
|
}
|
|
509
611
|
/**
|
|
510
612
|
* Retrieves a specific deployment configuration.
|
|
@@ -514,7 +616,12 @@ export class LTITool {
|
|
|
514
616
|
* @returns Deployment configuration if found, undefined otherwise
|
|
515
617
|
*/
|
|
516
618
|
async getDeployment(clientId, deploymentId) {
|
|
517
|
-
|
|
619
|
+
try {
|
|
620
|
+
return await this.config.storage.getDeployment(clientId, deploymentId);
|
|
621
|
+
}
|
|
622
|
+
catch (error) {
|
|
623
|
+
throw new Error(`[Deployment] Retrieval failed for client '${clientId}', deployment '${deploymentId}': ${formatError(error)}`);
|
|
624
|
+
}
|
|
518
625
|
}
|
|
519
626
|
/**
|
|
520
627
|
* Adds a new deployment to an existing client.
|
|
@@ -524,7 +631,12 @@ export class LTITool {
|
|
|
524
631
|
* @returns The generated deployment ID
|
|
525
632
|
*/
|
|
526
633
|
async addDeployment(clientId, deployment) {
|
|
527
|
-
|
|
634
|
+
try {
|
|
635
|
+
return await this.config.storage.addDeployment(clientId, deployment);
|
|
636
|
+
}
|
|
637
|
+
catch (error) {
|
|
638
|
+
throw new Error(`[Deployment] Creation failed for client '${clientId}': ${formatError(error)}`);
|
|
639
|
+
}
|
|
528
640
|
}
|
|
529
641
|
/**
|
|
530
642
|
* Updates an existing deployment configuration.
|
|
@@ -534,7 +646,12 @@ export class LTITool {
|
|
|
534
646
|
* @param deployment - Partial deployment object with fields to update
|
|
535
647
|
*/
|
|
536
648
|
async updateDeployment(clientId, deploymentId, deployment) {
|
|
537
|
-
|
|
649
|
+
try {
|
|
650
|
+
return await this.config.storage.updateDeployment(clientId, deploymentId, deployment);
|
|
651
|
+
}
|
|
652
|
+
catch (error) {
|
|
653
|
+
throw new Error(`Deployment update failed for client '${clientId}' and deployment '${deploymentId}': ${formatError(error)}`);
|
|
654
|
+
}
|
|
538
655
|
}
|
|
539
656
|
/**
|
|
540
657
|
* Removes a deployment from a client.
|
|
@@ -543,7 +660,12 @@ export class LTITool {
|
|
|
543
660
|
* @param deploymentId - Deployment identifier to remove
|
|
544
661
|
*/
|
|
545
662
|
async deleteDeployment(clientId, deploymentId) {
|
|
546
|
-
|
|
663
|
+
try {
|
|
664
|
+
return await this.config.storage.deleteDeployment(clientId, deploymentId);
|
|
665
|
+
}
|
|
666
|
+
catch (error) {
|
|
667
|
+
throw new Error(`[Deployment] Deletion failed for client '${clientId}', deployment '${deploymentId}': ${formatError(error)}`);
|
|
668
|
+
}
|
|
547
669
|
}
|
|
548
670
|
// Dynamic Registration Session Management
|
|
549
671
|
/**
|
|
@@ -554,7 +676,12 @@ export class LTITool {
|
|
|
554
676
|
* @param session - Registration session data including platform config and tokens
|
|
555
677
|
*/
|
|
556
678
|
async setRegistrationSession(sessionId, session) {
|
|
557
|
-
|
|
679
|
+
try {
|
|
680
|
+
return await this.config.storage.setRegistrationSession(sessionId, session);
|
|
681
|
+
}
|
|
682
|
+
catch (error) {
|
|
683
|
+
throw new Error(`[Dynamic Registration] Session storage failed for ID '${sessionId}': ${formatError(error)}`);
|
|
684
|
+
}
|
|
558
685
|
}
|
|
559
686
|
/**
|
|
560
687
|
* Retrieves a registration session by its ID for validation during completion.
|
|
@@ -564,7 +691,12 @@ export class LTITool {
|
|
|
564
691
|
* @returns Registration session if found and not expired, undefined otherwise
|
|
565
692
|
*/
|
|
566
693
|
async getRegistrationSession(sessionId) {
|
|
567
|
-
|
|
694
|
+
try {
|
|
695
|
+
return await this.config.storage.getRegistrationSession(sessionId);
|
|
696
|
+
}
|
|
697
|
+
catch (error) {
|
|
698
|
+
throw new Error(`[Dynamic Registration] Session retrieval failed for ID '${sessionId}': ${formatError(error)}`);
|
|
699
|
+
}
|
|
568
700
|
}
|
|
569
701
|
/**
|
|
570
702
|
* Removes a registration session from storage after completion or expiration.
|
|
@@ -573,6 +705,37 @@ export class LTITool {
|
|
|
573
705
|
* @param sessionId - Unique session identifier to delete
|
|
574
706
|
*/
|
|
575
707
|
async deleteRegistrationSession(sessionId) {
|
|
576
|
-
|
|
708
|
+
try {
|
|
709
|
+
return await this.config.storage.deleteRegistrationSession(sessionId);
|
|
710
|
+
}
|
|
711
|
+
catch (error) {
|
|
712
|
+
throw new Error(`[Dynamic Registration] Session deletion failed for ID '${sessionId}': ${formatError(error)}`);
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
/**
|
|
717
|
+
* Builds the authorization URL for LTI 1.3 OIDC authentication flow.
|
|
718
|
+
*
|
|
719
|
+
* @param launchConfig - Launch configuration containing auth endpoints
|
|
720
|
+
* @param validatedParams - Validated login parameters
|
|
721
|
+
* @param state - State JWT for CSRF protection
|
|
722
|
+
* @param nonce - Nonce for replay attack prevention
|
|
723
|
+
* @returns Complete authorization URL with all required parameters
|
|
724
|
+
*/
|
|
725
|
+
function buildAuthUrl(launchConfig, validatedParams, state, nonce) {
|
|
726
|
+
const authUrl = new URL(launchConfig.authUrl);
|
|
727
|
+
authUrl.searchParams.set('scope', 'openid');
|
|
728
|
+
authUrl.searchParams.set('response_type', 'id_token');
|
|
729
|
+
authUrl.searchParams.set('response_mode', 'form_post');
|
|
730
|
+
authUrl.searchParams.set('prompt', 'none');
|
|
731
|
+
authUrl.searchParams.set('client_id', validatedParams.client_id);
|
|
732
|
+
authUrl.searchParams.set('redirect_uri', validatedParams.launchUrl.toString());
|
|
733
|
+
authUrl.searchParams.set('login_hint', validatedParams.login_hint);
|
|
734
|
+
authUrl.searchParams.set('state', state);
|
|
735
|
+
authUrl.searchParams.set('nonce', nonce);
|
|
736
|
+
authUrl.searchParams.set('lti_deployment_id', validatedParams.lti_deployment_id);
|
|
737
|
+
if (validatedParams.lti_message_hint) {
|
|
738
|
+
authUrl.searchParams.set('lti_message_hint', validatedParams.lti_message_hint);
|
|
577
739
|
}
|
|
740
|
+
return authUrl.toString();
|
|
578
741
|
}
|