@lti-tool/core 0.13.1 → 0.14.0

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/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
- const validatedParams = HandleLoginParamsSchema.parse(params);
140
-
141
- const nonce = crypto.randomUUID();
142
-
143
- // Store nonce with expiration for replay attack prevention
144
- const nonceExpirationSeconds = this.config.security?.nonceExpirationSeconds ?? 600;
145
- const nonceExpiresAt = new Date(Date.now() + nonceExpirationSeconds * 1000);
146
- await this.config.storage.storeNonce(nonce, nonceExpiresAt);
147
-
148
- const state = await new SignJWT({
149
- nonce,
150
- iss: validatedParams.iss,
151
- client_id: validatedParams.client_id,
152
- target_link_uri: validatedParams.target_link_uri,
153
- exp:
154
- Math.floor(Date.now() / 1000) +
155
- (this.config.security?.stateExpirationSeconds ?? 600),
156
- })
157
- .setProtectedHeader({ alg: 'HS256' })
158
- .sign(this.config.stateSecret);
159
-
160
- const launchConfig = await getValidLaunchConfig(
161
- this.config.storage,
162
- validatedParams.iss,
163
- validatedParams.client_id,
164
- validatedParams.lti_deployment_id,
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
- if (validatedParams.lti_message_hint) {
180
- authUrl.searchParams.set('lti_message_hint', validatedParams.lti_message_hint);
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
- const validatedParams = VerifyLaunchParamsSchema.parse({ idToken, state });
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
- // 4. Verify our state JWT
227
- const { payload: stateData } = await jwtVerify(
228
- validatedParams.state,
229
- this.config.stateSecret,
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
- // 6. Verify client id matches (audience claim)
236
- if (validated.aud !== launchConfig.clientId) {
237
- throw new Error(
238
- `Invalid client_id: expected ${launchConfig.clientId}, got ${validated.aud}`,
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
- // 7. Verify nonce matches
243
- if (stateData.nonce !== validated.nonce) {
244
- throw new Error('Nonce mismatch');
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
- // 8. Check nonce hasn't been used before (prevent replay attacks)
248
- const isValidNonce = await this.config.storage.validateNonce(validated.nonce);
249
- if (!isValidNonce) {
250
- throw new Error('Nonce has already been used or expired');
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
- const publicJwk = await exportJWK(this.config.keyPair.publicKey);
263
- return {
264
- keys: [
265
- {
266
- ...publicJwk,
267
- use: 'sig',
268
- alg: 'RS256',
269
- kid: this.config.security?.keyId ?? 'main',
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
- const session = createSession(lti13JwtPayload);
283
- await this.config.storage.addSession(session);
284
- return session;
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
- const validatedSessionId = SessionIdSchema.parse(sessionId);
295
- return await this.config.storage.getSession(validatedSessionId);
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
- await this.agsService.submitScore(session, score);
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
- const response = await this.agsService.getScores(session);
335
- const data = await response.json();
336
- return ResultsSchema.parse(data);
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
- const response = await this.agsService.listLineItems(session);
352
- const data = await response.json();
353
- return LineItemsSchema.parse(data);
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
- const response = await this.agsService.getLineItem(session);
369
- const data = await response.json();
370
- return LineItemSchema.parse(data);
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
- const response = await this.agsService.createLineItem(session, createLineItem);
404
- const data = await response.json();
405
- return LineItemSchema.parse(data);
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
- const response = await this.agsService.updateLineItem(session, updateLineItem);
428
- const data = await response.json();
429
- return LineItemSchema.parse(data);
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
- await this.agsService.deleteLineItem(session);
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
- const response = await this.nrpsService.getMembers(session);
467
- const data = await response.json();
468
- const validated = NRPSContextMembershipResponseSchema.parse(data);
469
-
470
- // Transform to clean camelCase format
471
- return validated.members.map((member) => ({
472
- status: member.status,
473
- name: member.name,
474
- picture: member.picture,
475
- givenName: member.given_name,
476
- familyName: member.family_name,
477
- middleName: member.middle_name,
478
- email: member.email,
479
- userId: member.user_id,
480
- lisPersonSourcedId: member.lis_person_sourcedid,
481
- roles: member.roles,
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
- return await this.deepLinkingService.createResponse(session, contentItems);
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
- return await this.dynamicRegistrationService.fetchPlatformConfiguration(
544
- registrationRequest,
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
- return await this.dynamicRegistrationService.initiateDynamicRegistration(
565
- registrationRequest,
566
- requestPath,
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
- return await this.dynamicRegistrationService.completeDynamicRegistration(
586
- dynamicRegistrationForm,
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
- return await this.config.storage.listClients();
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
- const validated = UpdateClientSchema.parse(client);
612
- return await this.config.storage.updateClient(clientId, validated);
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
- return await this.config.storage.getClientById(clientId);
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
- const validated = AddClientSchema.parse(client);
633
- return await this.config.storage.addClient(validated);
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
- return await this.config.storage.deleteClient(clientId);
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
- return await this.config.storage.listDeployments(clientId);
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
- return await this.config.storage.getDeployment(clientId, deploymentId);
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
- return await this.config.storage.addDeployment(clientId, deployment);
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
- return await this.config.storage.updateDeployment(clientId, deploymentId, deployment);
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
- return await this.config.storage.deleteDeployment(clientId, deploymentId);
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
- return await this.config.storage.setRegistrationSession(sessionId, session);
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
- return await this.config.storage.getRegistrationSession(sessionId);
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
- return await this.config.storage.deleteRegistrationSession(sessionId);
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
  }