@lti-tool/core 0.14.0 → 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 +17 -0
- package/dist/services/ags.service.d.ts.map +1 -1
- package/dist/services/ags.service.js +10 -12
- package/dist/services/deepLinking.service.d.ts.map +1 -1
- package/dist/services/deepLinking.service.js +3 -2
- package/dist/services/dynamicRegistration.service.d.ts.map +1 -1
- package/dist/services/dynamicRegistration.service.js +6 -4
- package/dist/services/dynamicRegistrationHandlers/moodle.d.ts.map +1 -1
- package/dist/services/dynamicRegistrationHandlers/moodle.js +6 -4
- package/dist/services/nrps.service.d.ts.map +1 -1
- package/dist/services/nrps.service.js +2 -1
- package/dist/services/session.service.d.ts.map +1 -1
- package/dist/services/session.service.js +7 -2
- package/dist/services/token.service.d.ts.map +1 -1
- package/dist/services/token.service.js +2 -1
- package/dist/utils/htmlEscaping.d.ts +28 -0
- package/dist/utils/htmlEscaping.d.ts.map +1 -0
- package/dist/utils/htmlEscaping.js +34 -0
- package/dist/utils/ltiServiceFetch.d.ts +22 -0
- package/dist/utils/ltiServiceFetch.d.ts.map +1 -0
- package/dist/utils/ltiServiceFetch.js +35 -0
- package/package.json +1 -1
- package/src/services/ags.service.ts +10 -12
- package/src/services/deepLinking.service.ts +3 -2
- package/src/services/dynamicRegistration.service.ts +6 -4
- package/src/services/dynamicRegistrationHandlers/moodle.ts +6 -4
- package/src/services/nrps.service.ts +2 -1
- package/src/services/session.service.ts +7 -2
- package/src/services/token.service.ts +3 -1
- package/src/utils/htmlEscaping.ts +34 -0
- package/src/utils/ltiServiceFetch.ts +42 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,22 @@
|
|
|
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
|
+
|
|
14
|
+
## 0.14.1
|
|
15
|
+
|
|
16
|
+
### Patch Changes
|
|
17
|
+
|
|
18
|
+
- 25534f8: Fix score and results endpoint to use a cleansed ags line item endpoint without search params.
|
|
19
|
+
|
|
3
20
|
## 0.14.0
|
|
4
21
|
|
|
5
22
|
### Minor 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;
|
|
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.
|
|
@@ -54,7 +55,8 @@ export class AGSService {
|
|
|
54
55
|
activityProgress: score.activityProgress,
|
|
55
56
|
gradingProgress: score.gradingProgress,
|
|
56
57
|
};
|
|
57
|
-
const
|
|
58
|
+
const agsScoreEndpoint = `${session.services.ags.lineitem}/scores`;
|
|
59
|
+
const response = await ltiServiceFetch(agsScoreEndpoint, {
|
|
58
60
|
method: 'POST',
|
|
59
61
|
headers: {
|
|
60
62
|
Authorization: `Bearer ${token}`,
|
|
@@ -84,12 +86,8 @@ export class AGSService {
|
|
|
84
86
|
throw new Error('AGS line item not available for this session');
|
|
85
87
|
}
|
|
86
88
|
const token = await this.getAGSToken(session, 'https://purl.imsglobal.org/spec/lti-ags/scope/result.readonly');
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
const lineItemUrl = new URL(session.services.ags.lineitem);
|
|
90
|
-
lineItemUrl.search = '';
|
|
91
|
-
const resultsUrl = `${lineItemUrl.toString()}/results`;
|
|
92
|
-
const response = await fetch(resultsUrl, {
|
|
89
|
+
const resultsEndpoint = `${session.services.ags.lineitem}/results`;
|
|
90
|
+
const response = await ltiServiceFetch(resultsEndpoint, {
|
|
93
91
|
method: 'GET',
|
|
94
92
|
headers: {
|
|
95
93
|
Authorization: `Bearer ${token}`,
|
|
@@ -118,7 +116,7 @@ export class AGSService {
|
|
|
118
116
|
throw new Error('AGS list line items not available for this session');
|
|
119
117
|
}
|
|
120
118
|
const token = await this.getAGSToken(session, 'https://purl.imsglobal.org/spec/lti-ags/scope/lineitem.readonly');
|
|
121
|
-
const response = await
|
|
119
|
+
const response = await ltiServiceFetch(`${session.services.ags.lineitems}`, {
|
|
122
120
|
method: 'GET',
|
|
123
121
|
headers: {
|
|
124
122
|
Authorization: `Bearer ${token}`,
|
|
@@ -147,7 +145,7 @@ export class AGSService {
|
|
|
147
145
|
throw new Error('AGS line item not available for this session');
|
|
148
146
|
}
|
|
149
147
|
const token = await this.getAGSToken(session, 'https://purl.imsglobal.org/spec/lti-ags/scope/lineitem.readonly');
|
|
150
|
-
const response = await
|
|
148
|
+
const response = await ltiServiceFetch(`${session.services.ags.lineitem}`, {
|
|
151
149
|
method: 'GET',
|
|
152
150
|
headers: {
|
|
153
151
|
Authorization: `Bearer ${token}`,
|
|
@@ -182,7 +180,7 @@ export class AGSService {
|
|
|
182
180
|
throw new Error('AGS create line items not available for this session');
|
|
183
181
|
}
|
|
184
182
|
const token = await this.getAGSToken(session, 'https://purl.imsglobal.org/spec/lti-ags/scope/lineitem');
|
|
185
|
-
const response = await
|
|
183
|
+
const response = await ltiServiceFetch(`${session.services.ags.lineitems}`, {
|
|
186
184
|
method: 'POST',
|
|
187
185
|
headers: {
|
|
188
186
|
Authorization: `Bearer ${token}`,
|
|
@@ -216,7 +214,7 @@ export class AGSService {
|
|
|
216
214
|
throw new Error('AGS line item not available for this session');
|
|
217
215
|
}
|
|
218
216
|
const token = await this.getAGSToken(session, 'https://purl.imsglobal.org/spec/lti-ags/scope/lineitem');
|
|
219
|
-
const response = await
|
|
217
|
+
const response = await ltiServiceFetch(session.services.ags.lineitem, {
|
|
220
218
|
method: 'PUT',
|
|
221
219
|
headers: {
|
|
222
220
|
Authorization: `Bearer ${token}`,
|
|
@@ -245,7 +243,7 @@ export class AGSService {
|
|
|
245
243
|
throw new Error('AGS line item not available for this session');
|
|
246
244
|
}
|
|
247
245
|
const token = await this.getAGSToken(session, 'https://purl.imsglobal.org/spec/lti-ags/scope/lineitem');
|
|
248
|
-
const response = await
|
|
246
|
+
const response = await ltiServiceFetch(session.services.ags.lineitem, {
|
|
249
247
|
method: 'DELETE',
|
|
250
248
|
headers: {
|
|
251
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;
|
|
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;
|
|
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
|
|
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;
|
|
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 {
|
|
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
|
|
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;
|
|
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
|
|
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":"session.service.d.ts","sourceRoot":"","sources":["../../src/services/session.service.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,6BAA6B,CAAC;AAC9D,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,qBAAqB,CAAC;AAE3D;;;;;;GAMG;AAEH,wBAAgB,aAAa,CAAC,eAAe,EAAE,eAAe,GAAG,UAAU,
|
|
1
|
+
{"version":3,"file":"session.service.d.ts","sourceRoot":"","sources":["../../src/services/session.service.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,6BAA6B,CAAC;AAC9D,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,qBAAqB,CAAC;AAE3D;;;;;;GAMG;AAEH,wBAAgB,aAAa,CAAC,eAAe,EAAE,eAAe,GAAG,UAAU,CA4G1E"}
|
|
@@ -20,9 +20,14 @@ export function createSession(lti13JwtPayload) {
|
|
|
20
20
|
const isAdmin = roles.some((role) => role.includes('Administrator'));
|
|
21
21
|
const services = {};
|
|
22
22
|
if (agsEndpoint) {
|
|
23
|
+
let lineItemUrl;
|
|
24
|
+
if (agsEndpoint.lineitem) {
|
|
25
|
+
const url = new URL(agsEndpoint.lineitem);
|
|
26
|
+
lineItemUrl = `${url.origin}${url.pathname}`; // quirk: moodle adds a url search param
|
|
27
|
+
}
|
|
23
28
|
services.ags = {
|
|
24
|
-
lineitem:
|
|
25
|
-
lineitems: agsEndpoint.lineitems,
|
|
29
|
+
lineitem: lineItemUrl,
|
|
30
|
+
lineitems: agsEndpoint.lineitems, // quirk: keep the moodle url search param
|
|
26
31
|
scopes: agsEndpoint.scope || [],
|
|
27
32
|
};
|
|
28
33
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"token.service.d.ts","sourceRoot":"","sources":["../../src/services/token.service.ts"],"names":[],"mappings":"
|
|
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
|
|
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
|
+
* - `&` → `&`
|
|
7
|
+
* - `<` → `<`
|
|
8
|
+
* - `>` → `>`
|
|
9
|
+
* - `"` → `"`
|
|
10
|
+
* - `'` → `'`
|
|
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: '<script>alert("XSS")</script>'
|
|
19
|
+
*
|
|
20
|
+
* escapeHtml('Tom & Jerry');
|
|
21
|
+
* // Returns: 'Tom & Jerry'
|
|
22
|
+
*
|
|
23
|
+
* escapeHtml('<div class="foo">');
|
|
24
|
+
* // Returns: '<div class="foo">'
|
|
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
|
+
* - `&` → `&`
|
|
7
|
+
* - `<` → `<`
|
|
8
|
+
* - `>` → `>`
|
|
9
|
+
* - `"` → `"`
|
|
10
|
+
* - `'` → `'`
|
|
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: '<script>alert("XSS")</script>'
|
|
19
|
+
*
|
|
20
|
+
* escapeHtml('Tom & Jerry');
|
|
21
|
+
* // Returns: 'Tom & Jerry'
|
|
22
|
+
*
|
|
23
|
+
* escapeHtml('<div class="foo">');
|
|
24
|
+
* // Returns: '<div class="foo">'
|
|
25
|
+
* ```
|
|
26
|
+
*/
|
|
27
|
+
export function escapeHtml(str) {
|
|
28
|
+
return str
|
|
29
|
+
.replace(/&/g, '&')
|
|
30
|
+
.replace(/</g, '<')
|
|
31
|
+
.replace(/>/g, '>')
|
|
32
|
+
.replace(/"/g, '"')
|
|
33
|
+
.replace(/'/g, ''');
|
|
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
|
@@ -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
|
|
|
@@ -70,7 +71,8 @@ export class AGSService {
|
|
|
70
71
|
gradingProgress: score.gradingProgress,
|
|
71
72
|
};
|
|
72
73
|
|
|
73
|
-
const
|
|
74
|
+
const agsScoreEndpoint = `${session.services.ags.lineitem}/scores`;
|
|
75
|
+
const response = await ltiServiceFetch(agsScoreEndpoint, {
|
|
74
76
|
method: 'POST',
|
|
75
77
|
headers: {
|
|
76
78
|
Authorization: `Bearer ${token}`,
|
|
@@ -107,13 +109,9 @@ export class AGSService {
|
|
|
107
109
|
'https://purl.imsglobal.org/spec/lti-ags/scope/result.readonly',
|
|
108
110
|
);
|
|
109
111
|
|
|
110
|
-
|
|
111
|
-
// we cannot include a search / query param
|
|
112
|
-
const lineItemUrl = new URL(session.services.ags.lineitem);
|
|
113
|
-
lineItemUrl.search = '';
|
|
114
|
-
const resultsUrl = `${lineItemUrl.toString()}/results`;
|
|
112
|
+
const resultsEndpoint = `${session.services.ags.lineitem}/results`;
|
|
115
113
|
|
|
116
|
-
const response = await
|
|
114
|
+
const response = await ltiServiceFetch(resultsEndpoint, {
|
|
117
115
|
method: 'GET',
|
|
118
116
|
headers: {
|
|
119
117
|
Authorization: `Bearer ${token}`,
|
|
@@ -149,7 +147,7 @@ export class AGSService {
|
|
|
149
147
|
'https://purl.imsglobal.org/spec/lti-ags/scope/lineitem.readonly',
|
|
150
148
|
);
|
|
151
149
|
|
|
152
|
-
const response = await
|
|
150
|
+
const response = await ltiServiceFetch(`${session.services.ags.lineitems}`, {
|
|
153
151
|
method: 'GET',
|
|
154
152
|
headers: {
|
|
155
153
|
Authorization: `Bearer ${token}`,
|
|
@@ -185,7 +183,7 @@ export class AGSService {
|
|
|
185
183
|
'https://purl.imsglobal.org/spec/lti-ags/scope/lineitem.readonly',
|
|
186
184
|
);
|
|
187
185
|
|
|
188
|
-
const response = await
|
|
186
|
+
const response = await ltiServiceFetch(`${session.services.ags.lineitem}`, {
|
|
189
187
|
method: 'GET',
|
|
190
188
|
headers: {
|
|
191
189
|
Authorization: `Bearer ${token}`,
|
|
@@ -230,7 +228,7 @@ export class AGSService {
|
|
|
230
228
|
'https://purl.imsglobal.org/spec/lti-ags/scope/lineitem',
|
|
231
229
|
);
|
|
232
230
|
|
|
233
|
-
const response = await
|
|
231
|
+
const response = await ltiServiceFetch(`${session.services.ags.lineitems}`, {
|
|
234
232
|
method: 'POST',
|
|
235
233
|
headers: {
|
|
236
234
|
Authorization: `Bearer ${token}`,
|
|
@@ -274,7 +272,7 @@ export class AGSService {
|
|
|
274
272
|
'https://purl.imsglobal.org/spec/lti-ags/scope/lineitem',
|
|
275
273
|
);
|
|
276
274
|
|
|
277
|
-
const response = await
|
|
275
|
+
const response = await ltiServiceFetch(session.services.ags.lineitem, {
|
|
278
276
|
method: 'PUT',
|
|
279
277
|
headers: {
|
|
280
278
|
Authorization: `Bearer ${token}`,
|
|
@@ -310,7 +308,7 @@ export class AGSService {
|
|
|
310
308
|
'https://purl.imsglobal.org/spec/lti-ags/scope/lineitem',
|
|
311
309
|
);
|
|
312
310
|
|
|
313
|
-
const response = await
|
|
311
|
+
const response = await ltiServiceFetch(session.services.ags.lineitem, {
|
|
314
312
|
method: 'DELETE',
|
|
315
313
|
headers: {
|
|
316
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
|
|
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
|
|
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
|
|
55
|
+
const response = await ltiServiceFetch(session.services.nrps.membershipUrl, {
|
|
55
56
|
method: 'GET',
|
|
56
57
|
headers: {
|
|
57
58
|
Authorization: `Bearer ${token}`,
|
|
@@ -31,9 +31,14 @@ export function createSession(lti13JwtPayload: LTI13JwtPayload): LTISession {
|
|
|
31
31
|
|
|
32
32
|
const services: Record<string, unknown> = {};
|
|
33
33
|
if (agsEndpoint) {
|
|
34
|
+
let lineItemUrl: string | undefined;
|
|
35
|
+
if (agsEndpoint.lineitem) {
|
|
36
|
+
const url = new URL(agsEndpoint.lineitem);
|
|
37
|
+
lineItemUrl = `${url.origin}${url.pathname}`; // quirk: moodle adds a url search param
|
|
38
|
+
}
|
|
34
39
|
services.ags = {
|
|
35
|
-
lineitem:
|
|
36
|
-
lineitems: agsEndpoint.lineitems,
|
|
40
|
+
lineitem: lineItemUrl,
|
|
41
|
+
lineitems: agsEndpoint.lineitems, // quirk: keep the moodle url search param
|
|
37
42
|
scopes: agsEndpoint.scope || [],
|
|
38
43
|
};
|
|
39
44
|
}
|
|
@@ -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
|
|
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
|
+
* - `&` → `&`
|
|
7
|
+
* - `<` → `<`
|
|
8
|
+
* - `>` → `>`
|
|
9
|
+
* - `"` → `"`
|
|
10
|
+
* - `'` → `'`
|
|
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: '<script>alert("XSS")</script>'
|
|
19
|
+
*
|
|
20
|
+
* escapeHtml('Tom & Jerry');
|
|
21
|
+
* // Returns: 'Tom & Jerry'
|
|
22
|
+
*
|
|
23
|
+
* escapeHtml('<div class="foo">');
|
|
24
|
+
* // Returns: '<div class="foo">'
|
|
25
|
+
* ```
|
|
26
|
+
*/
|
|
27
|
+
export function escapeHtml(str: string): string {
|
|
28
|
+
return str
|
|
29
|
+
.replace(/&/g, '&')
|
|
30
|
+
.replace(/</g, '<')
|
|
31
|
+
.replace(/>/g, '>')
|
|
32
|
+
.replace(/"/g, '"')
|
|
33
|
+
.replace(/'/g, ''');
|
|
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
|
+
}
|