@lti-tool/core 0.14.1 → 1.0.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/CHANGELOG.md CHANGED
@@ -1,5 +1,16 @@
1
1
  # @lti-tool/core
2
2
 
3
+ ## 1.0.0
4
+
5
+ ### Major Changes
6
+
7
+ - 3bcba99: First stable release of LTI 1.3 toolkit for Node.js.
8
+ - Add ltiServiceFetch utility with automatic User-Agent injection for Canvas API compliance (effective January 2026)
9
+ - Add HTML escaping utility for XSS prevention in dynamic registration flows
10
+ - Complete LTI 1.3 specification: OIDC authentication, AGS, NRPS, Deep Linking, Dynamic Registration
11
+ - Serverless-native design optimized for AWS Lambda and Cloudflare Workers
12
+ - Cookie-free session management for iframe compatibility
13
+
3
14
  ## 0.14.1
4
15
 
5
16
  ### Patch Changes
@@ -1 +1 @@
1
- {"version":3,"file":"ags.service.d.ts","sourceRoot":"","sources":["../../src/services/ags.service.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,MAAM,CAAC;AAEvC,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,6BAA6B,CAAC;AAC9D,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,6BAA6B,CAAC;AAC9D,OAAO,KAAK,EACV,cAAc,EACd,cAAc,EACf,MAAM,yCAAyC,CAAC;AACjD,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,gDAAgD,CAAC;AAGtF,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,oBAAoB,CAAC;AAEvD;;;;;GAKG;AACH,qBAAa,UAAU;IASnB,OAAO,CAAC,YAAY;IACpB,OAAO,CAAC,OAAO;IACf,OAAO,CAAC,MAAM;IAVhB;;;;;;OAMG;gBAEO,YAAY,EAAE,YAAY,EAC1B,OAAO,EAAE,UAAU,EACnB,MAAM,EAAE,UAAU;IAG5B;;;;;;;;;;;;;;;;;;OAkBG;IACG,WAAW,CAAC,OAAO,EAAE,UAAU,EAAE,KAAK,EAAE,eAAe,GAAG,OAAO,CAAC,QAAQ,CAAC;IAkCjF;;;;;;;;;;;;;OAaG;IACG,SAAS,CAAC,OAAO,EAAE,UAAU,GAAG,OAAO,CAAC,QAAQ,CAAC;IAwBvD;;;;;;;;;;;;;OAaG;IACG,aAAa,CAAC,OAAO,EAAE,UAAU,GAAG,OAAO,CAAC,QAAQ,CAAC;IAsB3D;;;;;;;;;;;;;OAaG;IACG,WAAW,CAAC,OAAO,EAAE,UAAU,GAAG,OAAO,CAAC,QAAQ,CAAC;IAsBzD;;;;;;;;;;;;;;;;;;;OAmBG;IACG,cAAc,CAClB,OAAO,EAAE,UAAU,EACnB,cAAc,EAAE,cAAc,GAC7B,OAAO,CAAC,QAAQ,CAAC;IAuBpB;;;;;;;;;;;;;;;;;OAiBG;IACG,cAAc,CAClB,OAAO,EAAE,UAAU,EACnB,cAAc,EAAE,cAAc,GAC7B,OAAO,CAAC,QAAQ,CAAC;IAuBpB;;;;;;;;;;;;OAYG;IACG,cAAc,CAAC,OAAO,EAAE,UAAU,GAAG,OAAO,CAAC,QAAQ,CAAC;YAqB9C,WAAW;YAeX,mBAAmB;CAalC"}
1
+ {"version":3,"file":"ags.service.d.ts","sourceRoot":"","sources":["../../src/services/ags.service.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,MAAM,CAAC;AAEvC,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,6BAA6B,CAAC;AAC9D,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,6BAA6B,CAAC;AAC9D,OAAO,KAAK,EACV,cAAc,EACd,cAAc,EACf,MAAM,yCAAyC,CAAC;AACjD,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,gDAAgD,CAAC;AAItF,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,oBAAoB,CAAC;AAEvD;;;;;GAKG;AACH,qBAAa,UAAU;IASnB,OAAO,CAAC,YAAY;IACpB,OAAO,CAAC,OAAO;IACf,OAAO,CAAC,MAAM;IAVhB;;;;;;OAMG;gBAEO,YAAY,EAAE,YAAY,EAC1B,OAAO,EAAE,UAAU,EACnB,MAAM,EAAE,UAAU;IAG5B;;;;;;;;;;;;;;;;;;OAkBG;IACG,WAAW,CAAC,OAAO,EAAE,UAAU,EAAE,KAAK,EAAE,eAAe,GAAG,OAAO,CAAC,QAAQ,CAAC;IAkCjF;;;;;;;;;;;;;OAaG;IACG,SAAS,CAAC,OAAO,EAAE,UAAU,GAAG,OAAO,CAAC,QAAQ,CAAC;IAwBvD;;;;;;;;;;;;;OAaG;IACG,aAAa,CAAC,OAAO,EAAE,UAAU,GAAG,OAAO,CAAC,QAAQ,CAAC;IAsB3D;;;;;;;;;;;;;OAaG;IACG,WAAW,CAAC,OAAO,EAAE,UAAU,GAAG,OAAO,CAAC,QAAQ,CAAC;IAsBzD;;;;;;;;;;;;;;;;;;;OAmBG;IACG,cAAc,CAClB,OAAO,EAAE,UAAU,EACnB,cAAc,EAAE,cAAc,GAC7B,OAAO,CAAC,QAAQ,CAAC;IAuBpB;;;;;;;;;;;;;;;;;OAiBG;IACG,cAAc,CAClB,OAAO,EAAE,UAAU,EACnB,cAAc,EAAE,cAAc,GAC7B,OAAO,CAAC,QAAQ,CAAC;IAuBpB;;;;;;;;;;;;OAYG;IACG,cAAc,CAAC,OAAO,EAAE,UAAU,GAAG,OAAO,CAAC,QAAQ,CAAC;YAqB9C,WAAW;YAeX,mBAAmB;CAalC"}
@@ -1,4 +1,5 @@
1
1
  import { getValidLaunchConfig } from '../utils/launchConfigValidation.js';
2
+ import { ltiServiceFetch } from '../utils/ltiServiceFetch.js';
2
3
  /**
3
4
  * Assignment and Grade Services (AGS) implementation for LTI 1.3.
4
5
  * Provides methods to submit grades and scores back to the platform.
@@ -55,7 +56,7 @@ export class AGSService {
55
56
  gradingProgress: score.gradingProgress,
56
57
  };
57
58
  const agsScoreEndpoint = `${session.services.ags.lineitem}/scores`;
58
- const response = await fetch(agsScoreEndpoint, {
59
+ const response = await ltiServiceFetch(agsScoreEndpoint, {
59
60
  method: 'POST',
60
61
  headers: {
61
62
  Authorization: `Bearer ${token}`,
@@ -86,7 +87,7 @@ export class AGSService {
86
87
  }
87
88
  const token = await this.getAGSToken(session, 'https://purl.imsglobal.org/spec/lti-ags/scope/result.readonly');
88
89
  const resultsEndpoint = `${session.services.ags.lineitem}/results`;
89
- const response = await fetch(resultsEndpoint, {
90
+ const response = await ltiServiceFetch(resultsEndpoint, {
90
91
  method: 'GET',
91
92
  headers: {
92
93
  Authorization: `Bearer ${token}`,
@@ -115,7 +116,7 @@ export class AGSService {
115
116
  throw new Error('AGS list line items not available for this session');
116
117
  }
117
118
  const token = await this.getAGSToken(session, 'https://purl.imsglobal.org/spec/lti-ags/scope/lineitem.readonly');
118
- const response = await fetch(`${session.services.ags.lineitems}`, {
119
+ const response = await ltiServiceFetch(`${session.services.ags.lineitems}`, {
119
120
  method: 'GET',
120
121
  headers: {
121
122
  Authorization: `Bearer ${token}`,
@@ -144,7 +145,7 @@ export class AGSService {
144
145
  throw new Error('AGS line item not available for this session');
145
146
  }
146
147
  const token = await this.getAGSToken(session, 'https://purl.imsglobal.org/spec/lti-ags/scope/lineitem.readonly');
147
- const response = await fetch(`${session.services.ags.lineitem}`, {
148
+ const response = await ltiServiceFetch(`${session.services.ags.lineitem}`, {
148
149
  method: 'GET',
149
150
  headers: {
150
151
  Authorization: `Bearer ${token}`,
@@ -179,7 +180,7 @@ export class AGSService {
179
180
  throw new Error('AGS create line items not available for this session');
180
181
  }
181
182
  const token = await this.getAGSToken(session, 'https://purl.imsglobal.org/spec/lti-ags/scope/lineitem');
182
- const response = await fetch(`${session.services.ags.lineitems}`, {
183
+ const response = await ltiServiceFetch(`${session.services.ags.lineitems}`, {
183
184
  method: 'POST',
184
185
  headers: {
185
186
  Authorization: `Bearer ${token}`,
@@ -213,7 +214,7 @@ export class AGSService {
213
214
  throw new Error('AGS line item not available for this session');
214
215
  }
215
216
  const token = await this.getAGSToken(session, 'https://purl.imsglobal.org/spec/lti-ags/scope/lineitem');
216
- const response = await fetch(session.services.ags.lineitem, {
217
+ const response = await ltiServiceFetch(session.services.ags.lineitem, {
217
218
  method: 'PUT',
218
219
  headers: {
219
220
  Authorization: `Bearer ${token}`,
@@ -242,7 +243,7 @@ export class AGSService {
242
243
  throw new Error('AGS line item not available for this session');
243
244
  }
244
245
  const token = await this.getAGSToken(session, 'https://purl.imsglobal.org/spec/lti-ags/scope/lineitem');
245
- const response = await fetch(session.services.ags.lineitem, {
246
+ const response = await ltiServiceFetch(session.services.ags.lineitem, {
246
247
  method: 'DELETE',
247
248
  headers: {
248
249
  Authorization: `Bearer ${token}`,
@@ -1 +1 @@
1
- {"version":3,"file":"deepLinking.service.d.ts","sourceRoot":"","sources":["../../src/services/deepLinking.service.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,MAAM,CAAC;AAEvC,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,6BAA6B,CAAC;AAC9D,OAAO,KAAK,EAAE,sBAAsB,EAAE,MAAM,oDAAoD,CAAC;AAEjG;;;;;GAKG;AACH,qBAAa,kBAAkB;IAS3B,OAAO,CAAC,OAAO;IACf,OAAO,CAAC,MAAM;IACd,OAAO,CAAC,KAAK;IAVf;;;;;;OAMG;gBAEO,OAAO,EAAE,aAAa,EACtB,MAAM,EAAE,UAAU,EAClB,KAAK,SAAS;IAGxB;;;;;;;;;;;;;;;;;;;;OAoBG;IACG,cAAc,CAClB,OAAO,EAAE,UAAU,EACnB,YAAY,EAAE,sBAAsB,EAAE,GACrC,OAAO,CAAC,MAAM,CAAC;IAiBlB;;;;;;OAMG;YACW,oBAAoB;IA6BlC;;;;;;OAMG;IACH,OAAO,CAAC,sBAAsB;CAiB/B"}
1
+ {"version":3,"file":"deepLinking.service.d.ts","sourceRoot":"","sources":["../../src/services/deepLinking.service.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,MAAM,CAAC;AAEvC,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,6BAA6B,CAAC;AAC9D,OAAO,KAAK,EAAE,sBAAsB,EAAE,MAAM,oDAAoD,CAAC;AAGjG;;;;;GAKG;AACH,qBAAa,kBAAkB;IAS3B,OAAO,CAAC,OAAO;IACf,OAAO,CAAC,MAAM;IACd,OAAO,CAAC,KAAK;IAVf;;;;;;OAMG;gBAEO,OAAO,EAAE,aAAa,EACtB,MAAM,EAAE,UAAU,EAClB,KAAK,SAAS;IAGxB;;;;;;;;;;;;;;;;;;;;OAoBG;IACG,cAAc,CAClB,OAAO,EAAE,UAAU,EACnB,YAAY,EAAE,sBAAsB,EAAE,GACrC,OAAO,CAAC,MAAM,CAAC;IAiBlB;;;;;;OAMG;YACW,oBAAoB;IA6BlC;;;;;;OAMG;IACH,OAAO,CAAC,sBAAsB;CAiB/B"}
@@ -1,4 +1,5 @@
1
1
  import { SignJWT } from 'jose';
2
+ import { escapeHtml } from '../utils/htmlEscaping.js';
2
3
  /**
3
4
  * Deep Linking service for LTI 1.3.
4
5
  * Generates signed JWT responses containing selected content items to return to the platform.
@@ -97,8 +98,8 @@ export class DeepLinkingService {
97
98
  <title>Returning to platform...</title>
98
99
  </head>
99
100
  <body>
100
- <form id="deepLinkingForm" method="POST" action="${returnUrl}">
101
- <input type="hidden" name="JWT" value="${jwt}" />
101
+ <form id="deepLinkingForm" method="POST" action="${escapeHtml(returnUrl)}">
102
+ <input type="hidden" name="JWT" value="${escapeHtml(jwt)}" />
102
103
  </form>
103
104
  <script>
104
105
  document.getElementById('deepLinkingForm').submit();
@@ -1 +1 @@
1
- {"version":3,"file":"dynamicRegistration.service.d.ts","sourceRoot":"","sources":["../../src/services/dynamicRegistration.service.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,MAAM,CAAC;AAEvC,OAAO,KAAK,EAAE,yBAAyB,EAAE,MAAM,4BAA4B,CAAC;AAC5E,OAAO,KAAK,EAAE,6BAA6B,EAAE,MAAM,gDAAgD,CAAC;AACpG,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,6BAA6B,CAAC;AAC9D,OAAO,KAAK,EAAE,uBAAuB,EAAE,MAAM,uEAAuE,CAAC;AAErH,OAAO,EACL,KAAK,mBAAmB,EAEzB,MAAM,oEAAoE,CAAC;AAC5E,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,oEAAoE,CAAC;AAS9G;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2CG;AACH,qBAAa,0BAA0B;IASnC,OAAO,CAAC,OAAO;IACf,OAAO,CAAC,yBAAyB;IACjC,OAAO,CAAC,MAAM;IAVhB;;;;;;OAMG;gBAEO,OAAO,EAAE,UAAU,EACnB,yBAAyB,EAAE,yBAAyB,EACpD,MAAM,EAAE,UAAU;IAG5B;;;;;;;OAOG;IACG,0BAA0B,CAC9B,mBAAmB,EAAE,mBAAmB,GACvC,OAAO,CAAC,mBAAmB,CAAC;IAkC/B;;;;;;;;OAQG;IACG,2BAA2B,CAC/B,mBAAmB,EAAE,mBAAmB,EACxC,WAAW,EAAE,MAAM,GAClB,OAAO,CAAC,MAAM,CAAC;IAsClB;;;;;;;OAOG;IACG,2BAA2B,CAC/B,uBAAuB,EAAE,uBAAuB,GAC/C,OAAO,CAAC,MAAM,CAAC;IA2DlB;;;;;;OAMG;IACG,yBAAyB,CAC7B,YAAY,EAAE,MAAM,GACnB,OAAO,CAAC,6BAA6B,GAAG,SAAS,CAAC;IAQrD;;;;;;;OAOG;IACH,OAAO,CAAC,8BAA8B;IA2CtC;;;;;;;;;OASG;IACH,OAAO,CAAC,aAAa;IA0BrB;;;;;;OAMG;IACH,OAAO,CAAC,WAAW;IAoBnB;;;;;;;OAOG;IACH,OAAO,CAAC,wBAAwB;YA0ClB,mCAAmC;IAgBjD,OAAO,CAAC,0BAA0B;CAiDnC"}
1
+ {"version":3,"file":"dynamicRegistration.service.d.ts","sourceRoot":"","sources":["../../src/services/dynamicRegistration.service.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,MAAM,CAAC;AAEvC,OAAO,KAAK,EAAE,yBAAyB,EAAE,MAAM,4BAA4B,CAAC;AAC5E,OAAO,KAAK,EAAE,6BAA6B,EAAE,MAAM,gDAAgD,CAAC;AACpG,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,6BAA6B,CAAC;AAC9D,OAAO,KAAK,EAAE,uBAAuB,EAAE,MAAM,uEAAuE,CAAC;AAErH,OAAO,EACL,KAAK,mBAAmB,EAEzB,MAAM,oEAAoE,CAAC;AAC5E,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,oEAAoE,CAAC;AAW9G;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2CG;AACH,qBAAa,0BAA0B;IASnC,OAAO,CAAC,OAAO;IACf,OAAO,CAAC,yBAAyB;IACjC,OAAO,CAAC,MAAM;IAVhB;;;;;;OAMG;gBAEO,OAAO,EAAE,UAAU,EACnB,yBAAyB,EAAE,yBAAyB,EACpD,MAAM,EAAE,UAAU;IAG5B;;;;;;;OAOG;IACG,0BAA0B,CAC9B,mBAAmB,EAAE,mBAAmB,GACvC,OAAO,CAAC,mBAAmB,CAAC;IAkC/B;;;;;;;;OAQG;IACG,2BAA2B,CAC/B,mBAAmB,EAAE,mBAAmB,EACxC,WAAW,EAAE,MAAM,GAClB,OAAO,CAAC,MAAM,CAAC;IAsClB;;;;;;;OAOG;IACG,2BAA2B,CAC/B,uBAAuB,EAAE,uBAAuB,GAC/C,OAAO,CAAC,MAAM,CAAC;IA2DlB;;;;;;OAMG;IACG,yBAAyB,CAC7B,YAAY,EAAE,MAAM,GACnB,OAAO,CAAC,6BAA6B,GAAG,SAAS,CAAC;IAQrD;;;;;;;OAOG;IACH,OAAO,CAAC,8BAA8B;IA2CtC;;;;;;;;;OASG;IACH,OAAO,CAAC,aAAa;IA0BrB;;;;;;OAMG;IACH,OAAO,CAAC,WAAW;IAoBnB;;;;;;;OAOG;IACH,OAAO,CAAC,wBAAwB;YA0ClB,mCAAmC;IAgBjD,OAAO,CAAC,0BAA0B;CAiDnC"}
@@ -1,4 +1,6 @@
1
1
  import { openIDConfigurationSchema, } from '../schemas/lti13/dynamicRegistration/openIDConfiguration.schema.js';
2
+ import { escapeHtml } from '../utils/htmlEscaping.js';
3
+ import { ltiServiceFetch } from '../utils/ltiServiceFetch.js';
2
4
  import { handleMoodleDynamicRegistration, postRegistrationToMoodle, } from './dynamicRegistrationHandlers/moodle.js';
3
5
  /**
4
6
  * Service for handling LTI 1.3 dynamic registration workflows.
@@ -70,7 +72,7 @@ export class DynamicRegistrationService {
70
72
  */
71
73
  async fetchPlatformConfiguration(registrationRequest) {
72
74
  const { openid_configuration, registration_token } = registrationRequest;
73
- const response = await fetch(openid_configuration, {
75
+ const response = await ltiServiceFetch(openid_configuration, {
74
76
  method: 'GET',
75
77
  headers: {
76
78
  // only include registration token if it was provided
@@ -349,11 +351,11 @@ export class DynamicRegistrationService {
349
351
  <div class="card-body">
350
352
  <dl class="row">
351
353
  <dt class="col-sm-3">Tool Name:</dt>
352
- <dd class="col-sm-9">${registrationResponse.client_name}</dd>
354
+ <dd class="col-sm-9">${escapeHtml(registrationResponse.client_name)}</dd>
353
355
  <dt class="col-sm-3">Client ID:</dt>
354
- <dd class="col-sm-9"><code>${registrationResponse.client_id}</code></dd>
356
+ <dd class="col-sm-9"><code>${escapeHtml(registrationResponse.client_id)}</code></dd>
355
357
  <dt class="col-sm-3">Deployment ID:</dt>
356
- <dd class="col-sm-9"><code>${registrationResponse['https://purl.imsglobal.org/spec/lti-tool-configuration'].deployment_id || 'default'}</code></dd>
358
+ <dd class="col-sm-9"><code>${escapeHtml(registrationResponse['https://purl.imsglobal.org/spec/lti-tool-configuration'].deployment_id || 'default')}</code></dd>
357
359
  </dl>
358
360
  </div>
359
361
  </div>
@@ -1 +1 @@
1
- {"version":3,"file":"moodle.d.ts","sourceRoot":"","sources":["../../../src/services/dynamicRegistrationHandlers/moodle.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,MAAM,CAAC;AAEvC,OAAO,EAEL,KAAK,mBAAmB,EACxB,KAAK,oBAAoB,EAC1B,MAAM,eAAe,CAAC;AAQvB;;;;;;;;;;;;;;;;;;;GAmBG;AAEH,wBAAgB,+BAA+B,CAC7C,mBAAmB,EAAE,mBAAmB,EACxC,WAAW,EAAE,MAAM,EACnB,YAAY,EAAE,MAAM,GACnB,MAAM,CA2FR;AAED;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,wBAAsB,wBAAwB,CAC5C,oBAAoB,EAAE,MAAM,EAC5B,mBAAmB,EAAE,OAAO,EAC5B,MAAM,EAAE,UAAU,EAClB,iBAAiB,CAAC,EAAE,MAAM,GACzB,OAAO,CAAC,oBAAoB,CAAC,CAuB/B"}
1
+ {"version":3,"file":"moodle.d.ts","sourceRoot":"","sources":["../../../src/services/dynamicRegistrationHandlers/moodle.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,MAAM,CAAC;AAEvC,OAAO,EAEL,KAAK,mBAAmB,EACxB,KAAK,oBAAoB,EAC1B,MAAM,eAAe,CAAC;AAUvB;;;;;;;;;;;;;;;;;;;GAmBG;AAEH,wBAAgB,+BAA+B,CAC7C,mBAAmB,EAAE,mBAAmB,EACxC,WAAW,EAAE,MAAM,EACnB,YAAY,EAAE,MAAM,GACnB,MAAM,CA2FR;AAED;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,wBAAsB,wBAAwB,CAC5C,oBAAoB,EAAE,MAAM,EAC5B,mBAAmB,EAAE,OAAO,EAC5B,MAAM,EAAE,UAAU,EAClB,iBAAiB,CAAC,EAAE,MAAM,GACzB,OAAO,CAAC,oBAAoB,CAAC,CAuB/B"}
@@ -1,5 +1,7 @@
1
1
  import { RegistrationResponseSchema, } from '../../schemas';
2
- import { getAGSScopes, hasAGSSupport, hasDeepLinkingSupport, hasNRPSSupport, } from '../../utils/ltiPlatformCapabilities';
2
+ import { escapeHtml } from '../../utils/htmlEscaping.js';
3
+ import { getAGSScopes, hasAGSSupport, hasDeepLinkingSupport, hasNRPSSupport, } from '../../utils/ltiPlatformCapabilities.js';
4
+ import { ltiServiceFetch } from '../../utils/ltiServiceFetch.js';
3
5
  /**
4
6
  * Generates Moodle-specific dynamic registration form HTML with service selection options.
5
7
  * Creates a Bootstrap 5 form that detects available LTI Advantage services from the platform
@@ -38,7 +40,7 @@ export function handleMoodleDynamicRegistration(openIdConfiguration, currentPath
38
40
  <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-sRIl4kxILFvY47J16cr9ZwB07vP4J8+LH7qKQnuqkuIAvNWLzeN8tE5YBujZqJLB" crossorigin="anonymous">
39
41
  </head>
40
42
  <body class="container mt-4">
41
- <form method="POST" action="${completeAction}">
43
+ <form method="POST" action="${escapeHtml(completeAction)}">
42
44
  <div class="mb-3">
43
45
  <label class="form-label">Available Services</label>
44
46
  ${hasAGS
@@ -98,7 +100,7 @@ export function handleMoodleDynamicRegistration(openIdConfiguration, currentPath
98
100
  </div>
99
101
  </div>
100
102
 
101
- <input type="hidden" name="sessionToken" value="${sessionToken}">
103
+ <input type="hidden" name="sessionToken" value="${escapeHtml(sessionToken)}">
102
104
 
103
105
  <button type="submit" class="btn btn-primary">Register Tool</button>
104
106
  </form>
@@ -134,7 +136,7 @@ export async function postRegistrationToMoodle(registrationEndpoint, registratio
134
136
  if (registrationToken) {
135
137
  headers['Authorization'] = `Bearer ${registrationToken}`;
136
138
  }
137
- const response = await fetch(registrationEndpoint, {
139
+ const response = await ltiServiceFetch(registrationEndpoint, {
138
140
  method: 'POST',
139
141
  headers,
140
142
  body: JSON.stringify(registrationPayload),
@@ -1 +1 @@
1
- {"version":3,"file":"nrps.service.d.ts","sourceRoot":"","sources":["../../src/services/nrps.service.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,MAAM,CAAC;AAEvC,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,6BAA6B,CAAC;AAC9D,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,6BAA6B,CAAC;AAG9D,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,oBAAoB,CAAC;AAEvD;;;;;GAKG;AACH,qBAAa,WAAW;IASpB,OAAO,CAAC,YAAY;IACpB,OAAO,CAAC,OAAO;IACf,OAAO,CAAC,MAAM;IAVhB;;;;;;OAMG;gBAEO,YAAY,EAAE,YAAY,EAC1B,OAAO,EAAE,UAAU,EACnB,MAAM,EAAE,UAAU;IAG5B;;;;;;;;;;;;;;OAcG;IACG,UAAU,CAAC,OAAO,EAAE,UAAU,GAAG,OAAO,CAAC,QAAQ,CAAC;YAsB1C,YAAY;YAeZ,oBAAoB;CAanC"}
1
+ {"version":3,"file":"nrps.service.d.ts","sourceRoot":"","sources":["../../src/services/nrps.service.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,MAAM,CAAC;AAEvC,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,6BAA6B,CAAC;AAC9D,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,6BAA6B,CAAC;AAI9D,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,oBAAoB,CAAC;AAEvD;;;;;GAKG;AACH,qBAAa,WAAW;IASpB,OAAO,CAAC,YAAY;IACpB,OAAO,CAAC,OAAO;IACf,OAAO,CAAC,MAAM;IAVhB;;;;;;OAMG;gBAEO,YAAY,EAAE,YAAY,EAC1B,OAAO,EAAE,UAAU,EACnB,MAAM,EAAE,UAAU;IAG5B;;;;;;;;;;;;;;OAcG;IACG,UAAU,CAAC,OAAO,EAAE,UAAU,GAAG,OAAO,CAAC,QAAQ,CAAC;YAsB1C,YAAY;YAeZ,oBAAoB;CAanC"}
@@ -1,4 +1,5 @@
1
1
  import { getValidLaunchConfig } from '../utils/launchConfigValidation.js';
2
+ import { ltiServiceFetch } from '../utils/ltiServiceFetch.js';
2
3
  /**
3
4
  * Names and Role Provisioning Services (NRPS) implementation for LTI 1.3.
4
5
  * Provides methods to retrieve course membership and user information from the platform.
@@ -41,7 +42,7 @@ export class NRPSService {
41
42
  throw new Error('NRPS not available for this session');
42
43
  }
43
44
  const token = await this.getNRPSToken(session, 'https://purl.imsglobal.org/spec/lti-nrps/scope/contextmembership.readonly');
44
- const response = await fetch(session.services.nrps.membershipUrl, {
45
+ const response = await ltiServiceFetch(session.services.nrps.membershipUrl, {
45
46
  method: 'GET',
46
47
  headers: {
47
48
  Authorization: `Bearer ${token}`,
@@ -1 +1 @@
1
- {"version":3,"file":"token.service.d.ts","sourceRoot":"","sources":["../../src/services/token.service.ts"],"names":[],"mappings":"AAEA;;;;;;;;GAQG;AACH,qBAAa,YAAY;IAQrB,OAAO,CAAC,OAAO;IACf,OAAO,CAAC,KAAK;IARf;;;;;OAKG;gBAEO,OAAO,EAAE,aAAa,EACtB,KAAK,SAAS;IAGxB;;;;;;OAMG;IACG,qBAAqB,CAAC,QAAQ,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC;IAiBhF;;;;;;;;OAQG;IACG,cAAc,CAClB,QAAQ,EAAE,MAAM,EAChB,QAAQ,EAAE,MAAM,EAChB,KAAK,EAAE,MAAM,GACZ,OAAO,CAAC,MAAM,CAAC;CA6BnB"}
1
+ {"version":3,"file":"token.service.d.ts","sourceRoot":"","sources":["../../src/services/token.service.ts"],"names":[],"mappings":"AAIA;;;;;;;;GAQG;AACH,qBAAa,YAAY;IAQrB,OAAO,CAAC,OAAO;IACf,OAAO,CAAC,KAAK;IARf;;;;;OAKG;gBAEO,OAAO,EAAE,aAAa,EACtB,KAAK,SAAS;IAGxB;;;;;;OAMG;IACG,qBAAqB,CAAC,QAAQ,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC;IAiBhF;;;;;;;;OAQG;IACG,cAAc,CAClB,QAAQ,EAAE,MAAM,EAChB,QAAQ,EAAE,MAAM,EAChB,KAAK,EAAE,MAAM,GACZ,OAAO,CAAC,MAAM,CAAC;CA6BnB"}
@@ -1,4 +1,5 @@
1
1
  import { SignJWT } from 'jose';
2
+ import { ltiServiceFetch } from '../utils/ltiServiceFetch.js';
2
3
  /**
3
4
  * Service for handling OAuth2 client credentials flow and JWT client assertions.
4
5
  * Used for obtaining bearer tokens to access LTI Advantage services (AGS, NRPS, etc.).
@@ -55,7 +56,7 @@ export class TokenService {
55
56
  */
56
57
  async getBearerToken(clientId, tokenUrl, scope) {
57
58
  const assertion = await this.createClientAssertion(clientId, tokenUrl);
58
- const response = await fetch(tokenUrl, {
59
+ const response = await ltiServiceFetch(tokenUrl, {
59
60
  method: 'POST',
60
61
  headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
61
62
  body: new URLSearchParams({
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Escapes HTML special characters to prevent XSS attacks when inserting
3
+ * untrusted content into HTML.
4
+ *
5
+ * Converts the following characters to their HTML entity equivalents:
6
+ * - `&` → `&amp;`
7
+ * - `<` → `&lt;`
8
+ * - `>` → `&gt;`
9
+ * - `"` → `&quot;`
10
+ * - `'` → `&#39;`
11
+ *
12
+ * @param str - The string containing potentially unsafe HTML characters
13
+ * @returns The escaped string safe for insertion into HTML content or attributes
14
+ *
15
+ * @example
16
+ * ```typescript
17
+ * escapeHtml('<script>alert("XSS")</script>');
18
+ * // Returns: '&lt;script&gt;alert(&quot;XSS&quot;)&lt;/script&gt;'
19
+ *
20
+ * escapeHtml('Tom & Jerry');
21
+ * // Returns: 'Tom &amp; Jerry'
22
+ *
23
+ * escapeHtml('<div class="foo">');
24
+ * // Returns: '&lt;div class=&quot;foo&quot;&gt;'
25
+ * ```
26
+ */
27
+ export declare function escapeHtml(str: string): string;
28
+ //# sourceMappingURL=htmlEscaping.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"htmlEscaping.d.ts","sourceRoot":"","sources":["../../src/utils/htmlEscaping.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AACH,wBAAgB,UAAU,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAO9C"}
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Escapes HTML special characters to prevent XSS attacks when inserting
3
+ * untrusted content into HTML.
4
+ *
5
+ * Converts the following characters to their HTML entity equivalents:
6
+ * - `&` → `&amp;`
7
+ * - `<` → `&lt;`
8
+ * - `>` → `&gt;`
9
+ * - `"` → `&quot;`
10
+ * - `'` → `&#39;`
11
+ *
12
+ * @param str - The string containing potentially unsafe HTML characters
13
+ * @returns The escaped string safe for insertion into HTML content or attributes
14
+ *
15
+ * @example
16
+ * ```typescript
17
+ * escapeHtml('<script>alert("XSS")</script>');
18
+ * // Returns: '&lt;script&gt;alert(&quot;XSS&quot;)&lt;/script&gt;'
19
+ *
20
+ * escapeHtml('Tom & Jerry');
21
+ * // Returns: 'Tom &amp; Jerry'
22
+ *
23
+ * escapeHtml('<div class="foo">');
24
+ * // Returns: '&lt;div class=&quot;foo&quot;&gt;'
25
+ * ```
26
+ */
27
+ export function escapeHtml(str) {
28
+ return str
29
+ .replace(/&/g, '&amp;')
30
+ .replace(/</g, '&lt;')
31
+ .replace(/>/g, '&gt;')
32
+ .replace(/"/g, '&quot;')
33
+ .replace(/'/g, '&#39;');
34
+ }
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Wrapper around fetch() that automatically adds User-Agent header for LTI service requests.
3
+ *
4
+ * Canvas enforces User-Agent headers on all API requests starting January 2026.
5
+ * This wrapper ensures compliance while allowing user override if needed.
6
+ *
7
+ * @param url - Request URL (string or URL object)
8
+ * @param init - Fetch options (headers, method, body, etc.)
9
+ * @returns Promise resolving to Response
10
+ *
11
+ * @see https://community.canvaslms.com/t5/Releases-Production/Canvas-Release-Notes-2026-01-17/ta-p/616001
12
+ *
13
+ * @example
14
+ * ```typescript
15
+ * const response = await ltiServiceFetch('https://api.example.com/scores', {
16
+ * method: 'POST',
17
+ * headers: { 'Content-Type': 'application/json' },
18
+ * body: JSON.stringify({ score: 95 })
19
+ * });
20
+ * */
21
+ export declare function ltiServiceFetch(url: string | URL, init?: RequestInit): Promise<Response>;
22
+ //# sourceMappingURL=ltiServiceFetch.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ltiServiceFetch.d.ts","sourceRoot":"","sources":["../../src/utils/ltiServiceFetch.ts"],"names":[],"mappings":"AAEA;;;;;;;;;;;;;;;;;;;KAmBK;AAEL,wBAAsB,eAAe,CACnC,GAAG,EAAE,MAAM,GAAG,GAAG,EACjB,IAAI,CAAC,EAAE,WAAW,GACjB,OAAO,CAAC,QAAQ,CAAC,CAenB"}
@@ -0,0 +1,35 @@
1
+ import * as packageJson from '../../package.json';
2
+ /**
3
+ * Wrapper around fetch() that automatically adds User-Agent header for LTI service requests.
4
+ *
5
+ * Canvas enforces User-Agent headers on all API requests starting January 2026.
6
+ * This wrapper ensures compliance while allowing user override if needed.
7
+ *
8
+ * @param url - Request URL (string or URL object)
9
+ * @param init - Fetch options (headers, method, body, etc.)
10
+ * @returns Promise resolving to Response
11
+ *
12
+ * @see https://community.canvaslms.com/t5/Releases-Production/Canvas-Release-Notes-2026-01-17/ta-p/616001
13
+ *
14
+ * @example
15
+ * ```typescript
16
+ * const response = await ltiServiceFetch('https://api.example.com/scores', {
17
+ * method: 'POST',
18
+ * headers: { 'Content-Type': 'application/json' },
19
+ * body: JSON.stringify({ score: 95 })
20
+ * });
21
+ * */
22
+ // oxlint-disable-next-line require-await
23
+ export async function ltiServiceFetch(url, init) {
24
+ // Create Headers object from init.headers (handles all header input types)
25
+ const headers = new Headers(init?.headers);
26
+ // Add User-Agent only if not already present (allows override)
27
+ if (!headers.has('User-Agent')) {
28
+ headers.set('User-Agent', `lti-tool/${packageJson.version} (https://github.com/lti-tool/lti-tool)`);
29
+ }
30
+ // Call fetch with merged headers
31
+ return fetch(url, {
32
+ ...init,
33
+ headers,
34
+ });
35
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lti-tool/core",
3
- "version": "0.14.1",
3
+ "version": "1.0.0",
4
4
  "description": "LTI 1.3 implementation for Node.js",
5
5
  "keywords": [
6
6
  "lti",
@@ -8,6 +8,7 @@ import type {
8
8
  } from '../schemas/lti13/ags/lineItem.schema.js';
9
9
  import type { ScoreSubmission } from '../schemas/lti13/ags/scoreSubmission.schema.js';
10
10
  import { getValidLaunchConfig } from '../utils/launchConfigValidation.js';
11
+ import { ltiServiceFetch } from '../utils/ltiServiceFetch.js';
11
12
 
12
13
  import type { TokenService } from './token.service.js';
13
14
 
@@ -71,7 +72,7 @@ export class AGSService {
71
72
  };
72
73
 
73
74
  const agsScoreEndpoint = `${session.services.ags.lineitem}/scores`;
74
- const response = await fetch(agsScoreEndpoint, {
75
+ const response = await ltiServiceFetch(agsScoreEndpoint, {
75
76
  method: 'POST',
76
77
  headers: {
77
78
  Authorization: `Bearer ${token}`,
@@ -110,7 +111,7 @@ export class AGSService {
110
111
 
111
112
  const resultsEndpoint = `${session.services.ags.lineitem}/results`;
112
113
 
113
- const response = await fetch(resultsEndpoint, {
114
+ const response = await ltiServiceFetch(resultsEndpoint, {
114
115
  method: 'GET',
115
116
  headers: {
116
117
  Authorization: `Bearer ${token}`,
@@ -146,7 +147,7 @@ export class AGSService {
146
147
  'https://purl.imsglobal.org/spec/lti-ags/scope/lineitem.readonly',
147
148
  );
148
149
 
149
- const response = await fetch(`${session.services.ags.lineitems}`, {
150
+ const response = await ltiServiceFetch(`${session.services.ags.lineitems}`, {
150
151
  method: 'GET',
151
152
  headers: {
152
153
  Authorization: `Bearer ${token}`,
@@ -182,7 +183,7 @@ export class AGSService {
182
183
  'https://purl.imsglobal.org/spec/lti-ags/scope/lineitem.readonly',
183
184
  );
184
185
 
185
- const response = await fetch(`${session.services.ags.lineitem}`, {
186
+ const response = await ltiServiceFetch(`${session.services.ags.lineitem}`, {
186
187
  method: 'GET',
187
188
  headers: {
188
189
  Authorization: `Bearer ${token}`,
@@ -227,7 +228,7 @@ export class AGSService {
227
228
  'https://purl.imsglobal.org/spec/lti-ags/scope/lineitem',
228
229
  );
229
230
 
230
- const response = await fetch(`${session.services.ags.lineitems}`, {
231
+ const response = await ltiServiceFetch(`${session.services.ags.lineitems}`, {
231
232
  method: 'POST',
232
233
  headers: {
233
234
  Authorization: `Bearer ${token}`,
@@ -271,7 +272,7 @@ export class AGSService {
271
272
  'https://purl.imsglobal.org/spec/lti-ags/scope/lineitem',
272
273
  );
273
274
 
274
- const response = await fetch(session.services.ags.lineitem, {
275
+ const response = await ltiServiceFetch(session.services.ags.lineitem, {
275
276
  method: 'PUT',
276
277
  headers: {
277
278
  Authorization: `Bearer ${token}`,
@@ -307,7 +308,7 @@ export class AGSService {
307
308
  'https://purl.imsglobal.org/spec/lti-ags/scope/lineitem',
308
309
  );
309
310
 
310
- const response = await fetch(session.services.ags.lineitem, {
311
+ const response = await ltiServiceFetch(session.services.ags.lineitem, {
311
312
  method: 'DELETE',
312
313
  headers: {
313
314
  Authorization: `Bearer ${token}`,
@@ -3,6 +3,7 @@ import type { BaseLogger } from 'pino';
3
3
 
4
4
  import type { LTISession } from '../interfaces/ltiSession.js';
5
5
  import type { DeepLinkingContentItem } from '../schemas/lti13/deepLinking/contentItem.schema.js';
6
+ import { escapeHtml } from '../utils/htmlEscaping.js';
6
7
 
7
8
  /**
8
9
  * Deep Linking service for LTI 1.3.
@@ -116,8 +117,8 @@ export class DeepLinkingService {
116
117
  <title>Returning to platform...</title>
117
118
  </head>
118
119
  <body>
119
- <form id="deepLinkingForm" method="POST" action="${returnUrl}">
120
- <input type="hidden" name="JWT" value="${jwt}" />
120
+ <form id="deepLinkingForm" method="POST" action="${escapeHtml(returnUrl)}">
121
+ <input type="hidden" name="JWT" value="${escapeHtml(jwt)}" />
121
122
  </form>
122
123
  <script>
123
124
  document.getElementById('deepLinkingForm').submit();
@@ -12,6 +12,8 @@ import {
12
12
  import type { RegistrationRequest } from '../schemas/lti13/dynamicRegistration/registrationRequest.schema.js';
13
13
  import type { RegistrationResponse } from '../schemas/lti13/dynamicRegistration/registrationResponse.schema.js';
14
14
  import type { ToolRegistrationPayload } from '../schemas/lti13/dynamicRegistration/toolRegistrationPayload.schema.js';
15
+ import { escapeHtml } from '../utils/htmlEscaping.js';
16
+ import { ltiServiceFetch } from '../utils/ltiServiceFetch.js';
15
17
 
16
18
  import {
17
19
  handleMoodleDynamicRegistration,
@@ -88,7 +90,7 @@ export class DynamicRegistrationService {
88
90
  registrationRequest: RegistrationRequest,
89
91
  ): Promise<OpenIDConfiguration> {
90
92
  const { openid_configuration, registration_token } = registrationRequest;
91
- const response = await fetch(openid_configuration, {
93
+ const response = await ltiServiceFetch(openid_configuration, {
92
94
  method: 'GET',
93
95
  headers: {
94
96
  // only include registration token if it was provided
@@ -461,11 +463,11 @@ export class DynamicRegistrationService {
461
463
  <div class="card-body">
462
464
  <dl class="row">
463
465
  <dt class="col-sm-3">Tool Name:</dt>
464
- <dd class="col-sm-9">${registrationResponse.client_name}</dd>
466
+ <dd class="col-sm-9">${escapeHtml(registrationResponse.client_name)}</dd>
465
467
  <dt class="col-sm-3">Client ID:</dt>
466
- <dd class="col-sm-9"><code>${registrationResponse.client_id}</code></dd>
468
+ <dd class="col-sm-9"><code>${escapeHtml(registrationResponse.client_id)}</code></dd>
467
469
  <dt class="col-sm-3">Deployment ID:</dt>
468
- <dd class="col-sm-9"><code>${registrationResponse['https://purl.imsglobal.org/spec/lti-tool-configuration'].deployment_id || 'default'}</code></dd>
470
+ <dd class="col-sm-9"><code>${escapeHtml(registrationResponse['https://purl.imsglobal.org/spec/lti-tool-configuration'].deployment_id || 'default')}</code></dd>
469
471
  </dl>
470
472
  </div>
471
473
  </div>
@@ -5,12 +5,14 @@ import {
5
5
  type OpenIDConfiguration,
6
6
  type RegistrationResponse,
7
7
  } from '../../schemas';
8
+ import { escapeHtml } from '../../utils/htmlEscaping.js';
8
9
  import {
9
10
  getAGSScopes,
10
11
  hasAGSSupport,
11
12
  hasDeepLinkingSupport,
12
13
  hasNRPSSupport,
13
- } from '../../utils/ltiPlatformCapabilities';
14
+ } from '../../utils/ltiPlatformCapabilities.js';
15
+ import { ltiServiceFetch } from '../../utils/ltiServiceFetch.js';
14
16
 
15
17
  /**
16
18
  * Generates Moodle-specific dynamic registration form HTML with service selection options.
@@ -55,7 +57,7 @@ export function handleMoodleDynamicRegistration(
55
57
  <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-sRIl4kxILFvY47J16cr9ZwB07vP4J8+LH7qKQnuqkuIAvNWLzeN8tE5YBujZqJLB" crossorigin="anonymous">
56
58
  </head>
57
59
  <body class="container mt-4">
58
- <form method="POST" action="${completeAction}">
60
+ <form method="POST" action="${escapeHtml(completeAction)}">
59
61
  <div class="mb-3">
60
62
  <label class="form-label">Available Services</label>
61
63
  ${
@@ -121,7 +123,7 @@ export function handleMoodleDynamicRegistration(
121
123
  </div>
122
124
  </div>
123
125
 
124
- <input type="hidden" name="sessionToken" value="${sessionToken}">
126
+ <input type="hidden" name="sessionToken" value="${escapeHtml(sessionToken)}">
125
127
 
126
128
  <button type="submit" class="btn btn-primary">Register Tool</button>
127
129
  </form>
@@ -164,7 +166,7 @@ export async function postRegistrationToMoodle(
164
166
  headers['Authorization'] = `Bearer ${registrationToken}`;
165
167
  }
166
168
 
167
- const response = await fetch(registrationEndpoint, {
169
+ const response = await ltiServiceFetch(registrationEndpoint, {
168
170
  method: 'POST',
169
171
  headers,
170
172
  body: JSON.stringify(registrationPayload),
@@ -3,6 +3,7 @@ import type { BaseLogger } from 'pino';
3
3
  import type { LTISession } from '../interfaces/ltiSession.js';
4
4
  import type { LTIStorage } from '../interfaces/ltiStorage.js';
5
5
  import { getValidLaunchConfig } from '../utils/launchConfigValidation.js';
6
+ import { ltiServiceFetch } from '../utils/ltiServiceFetch.js';
6
7
 
7
8
  import type { TokenService } from './token.service.js';
8
9
 
@@ -51,7 +52,7 @@ export class NRPSService {
51
52
  'https://purl.imsglobal.org/spec/lti-nrps/scope/contextmembership.readonly',
52
53
  );
53
54
 
54
- const response = await fetch(session.services.nrps.membershipUrl, {
55
+ const response = await ltiServiceFetch(session.services.nrps.membershipUrl, {
55
56
  method: 'GET',
56
57
  headers: {
57
58
  Authorization: `Bearer ${token}`,
@@ -1,5 +1,7 @@
1
1
  import { SignJWT } from 'jose';
2
2
 
3
+ import { ltiServiceFetch } from '../utils/ltiServiceFetch.js';
4
+
3
5
  /**
4
6
  * Service for handling OAuth2 client credentials flow and JWT client assertions.
5
7
  * Used for obtaining bearer tokens to access LTI Advantage services (AGS, NRPS, etc.).
@@ -61,7 +63,7 @@ export class TokenService {
61
63
  ): Promise<string> {
62
64
  const assertion = await this.createClientAssertion(clientId, tokenUrl);
63
65
 
64
- const response = await fetch(tokenUrl, {
66
+ const response = await ltiServiceFetch(tokenUrl, {
65
67
  method: 'POST',
66
68
  headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
67
69
  body: new URLSearchParams({
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Escapes HTML special characters to prevent XSS attacks when inserting
3
+ * untrusted content into HTML.
4
+ *
5
+ * Converts the following characters to their HTML entity equivalents:
6
+ * - `&` → `&amp;`
7
+ * - `<` → `&lt;`
8
+ * - `>` → `&gt;`
9
+ * - `"` → `&quot;`
10
+ * - `'` → `&#39;`
11
+ *
12
+ * @param str - The string containing potentially unsafe HTML characters
13
+ * @returns The escaped string safe for insertion into HTML content or attributes
14
+ *
15
+ * @example
16
+ * ```typescript
17
+ * escapeHtml('<script>alert("XSS")</script>');
18
+ * // Returns: '&lt;script&gt;alert(&quot;XSS&quot;)&lt;/script&gt;'
19
+ *
20
+ * escapeHtml('Tom & Jerry');
21
+ * // Returns: 'Tom &amp; Jerry'
22
+ *
23
+ * escapeHtml('<div class="foo">');
24
+ * // Returns: '&lt;div class=&quot;foo&quot;&gt;'
25
+ * ```
26
+ */
27
+ export function escapeHtml(str: string): string {
28
+ return str
29
+ .replace(/&/g, '&amp;')
30
+ .replace(/</g, '&lt;')
31
+ .replace(/>/g, '&gt;')
32
+ .replace(/"/g, '&quot;')
33
+ .replace(/'/g, '&#39;');
34
+ }
@@ -0,0 +1,42 @@
1
+ import * as packageJson from '../../package.json';
2
+
3
+ /**
4
+ * Wrapper around fetch() that automatically adds User-Agent header for LTI service requests.
5
+ *
6
+ * Canvas enforces User-Agent headers on all API requests starting January 2026.
7
+ * This wrapper ensures compliance while allowing user override if needed.
8
+ *
9
+ * @param url - Request URL (string or URL object)
10
+ * @param init - Fetch options (headers, method, body, etc.)
11
+ * @returns Promise resolving to Response
12
+ *
13
+ * @see https://community.canvaslms.com/t5/Releases-Production/Canvas-Release-Notes-2026-01-17/ta-p/616001
14
+ *
15
+ * @example
16
+ * ```typescript
17
+ * const response = await ltiServiceFetch('https://api.example.com/scores', {
18
+ * method: 'POST',
19
+ * headers: { 'Content-Type': 'application/json' },
20
+ * body: JSON.stringify({ score: 95 })
21
+ * });
22
+ * */
23
+ // oxlint-disable-next-line require-await
24
+ export async function ltiServiceFetch(
25
+ url: string | URL,
26
+ init?: RequestInit,
27
+ ): Promise<Response> {
28
+ // Create Headers object from init.headers (handles all header input types)
29
+ const headers = new Headers(init?.headers);
30
+ // Add User-Agent only if not already present (allows override)
31
+ if (!headers.has('User-Agent')) {
32
+ headers.set(
33
+ 'User-Agent',
34
+ `lti-tool/${packageJson.version} (https://github.com/lti-tool/lti-tool)`,
35
+ );
36
+ }
37
+ // Call fetch with merged headers
38
+ return fetch(url, {
39
+ ...init,
40
+ headers,
41
+ });
42
+ }