@lti-tool/core 0.11.0 → 0.12.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 +12 -0
- package/dist/interfaces/index.d.ts +1 -0
- package/dist/interfaces/index.d.ts.map +1 -1
- package/dist/interfaces/ltiConfig.d.ts +23 -0
- package/dist/interfaces/ltiConfig.d.ts.map +1 -1
- package/dist/interfaces/ltiDynamicRegistrationSession.d.ts +14 -0
- package/dist/interfaces/ltiDynamicRegistrationSession.d.ts.map +1 -0
- package/dist/interfaces/ltiDynamicRegistrationSession.js +1 -0
- package/dist/interfaces/ltiStorage.d.ts +22 -0
- package/dist/interfaces/ltiStorage.d.ts.map +1 -1
- package/dist/ltiTool.d.ts +64 -1
- package/dist/ltiTool.d.ts.map +1 -1
- package/dist/ltiTool.js +87 -1
- package/dist/schemas/index.d.ts +5 -0
- package/dist/schemas/index.d.ts.map +1 -1
- package/dist/schemas/index.js +4 -0
- package/dist/schemas/lti13/dynamicRegistration/ltiDynamicRegistration.schema.d.ts +34 -0
- package/dist/schemas/lti13/dynamicRegistration/ltiDynamicRegistration.schema.d.ts.map +1 -0
- package/dist/schemas/lti13/dynamicRegistration/ltiDynamicRegistration.schema.js +28 -0
- package/dist/schemas/lti13/dynamicRegistration/ltiMessages.schema.d.ts +101 -0
- package/dist/schemas/lti13/dynamicRegistration/ltiMessages.schema.d.ts.map +1 -0
- package/dist/schemas/lti13/dynamicRegistration/ltiMessages.schema.js +51 -0
- package/dist/schemas/lti13/dynamicRegistration/openIDConfiguration.schema.d.ts +59 -0
- package/dist/schemas/lti13/dynamicRegistration/openIDConfiguration.schema.d.ts.map +1 -0
- package/dist/schemas/lti13/dynamicRegistration/openIDConfiguration.schema.js +53 -0
- package/dist/schemas/lti13/dynamicRegistration/registrationRequest.schema.d.ts +7 -0
- package/dist/schemas/lti13/dynamicRegistration/registrationRequest.schema.d.ts.map +1 -0
- package/dist/schemas/lti13/dynamicRegistration/registrationRequest.schema.js +5 -0
- package/dist/schemas/lti13/dynamicRegistration/registrationResponse.schema.d.ts +73 -0
- package/dist/schemas/lti13/dynamicRegistration/registrationResponse.schema.d.ts.map +1 -0
- package/dist/schemas/lti13/dynamicRegistration/registrationResponse.schema.js +61 -0
- package/dist/schemas/lti13/dynamicRegistration/toolRegistrationPayload.schema.d.ts +103 -0
- package/dist/schemas/lti13/dynamicRegistration/toolRegistrationPayload.schema.d.ts.map +1 -0
- package/dist/schemas/lti13/dynamicRegistration/toolRegistrationPayload.schema.js +49 -0
- package/dist/services/dynamicRegistration.service.d.ts +121 -0
- package/dist/services/dynamicRegistration.service.d.ts.map +1 -0
- package/dist/services/dynamicRegistration.service.js +312 -0
- package/dist/services/dynamicRegistrationHandlers/moodle.d.ts +48 -0
- package/dist/services/dynamicRegistrationHandlers/moodle.d.ts.map +1 -0
- package/dist/services/dynamicRegistrationHandlers/moodle.js +152 -0
- package/dist/utils/ltiPlatformCapabilities.d.ts +65 -0
- package/dist/utils/ltiPlatformCapabilities.d.ts.map +1 -0
- package/dist/utils/ltiPlatformCapabilities.js +73 -0
- package/package.json +3 -3
- package/src/interfaces/index.ts +1 -0
- package/src/interfaces/ltiConfig.ts +25 -0
- package/src/interfaces/ltiDynamicRegistrationSession.ts +14 -0
- package/src/interfaces/ltiStorage.ts +32 -0
- package/src/ltiTool.ts +122 -1
- package/src/schemas/index.ts +17 -0
- package/src/schemas/lti13/dynamicRegistration/ltiDynamicRegistration.schema.ts +31 -0
- package/src/schemas/lti13/dynamicRegistration/ltiMessages.schema.ts +59 -0
- package/src/schemas/lti13/dynamicRegistration/openIDConfiguration.schema.ts +60 -0
- package/src/schemas/lti13/dynamicRegistration/registrationRequest.schema.ts +8 -0
- package/src/schemas/lti13/dynamicRegistration/registrationResponse.schema.ts +67 -0
- package/src/schemas/lti13/dynamicRegistration/toolRegistrationPayload.schema.ts +55 -0
- package/src/services/dynamicRegistration.service.ts +406 -0
- package/src/services/dynamicRegistrationHandlers/moodle.ts +184 -0
- package/src/utils/ltiPlatformCapabilities.ts +86 -0
|
@@ -0,0 +1,406 @@
|
|
|
1
|
+
import type { BaseLogger } from 'pino';
|
|
2
|
+
|
|
3
|
+
import type { DynamicRegistrationConfig } from '../interfaces/ltiConfig.js';
|
|
4
|
+
import type { LTIDynamicRegistrationSession } from '../interfaces/ltiDynamicRegistrationSession.js';
|
|
5
|
+
import type { LTIStorage } from '../interfaces/ltiStorage.js';
|
|
6
|
+
import type { DynamicRegistrationForm } from '../schemas/lti13/dynamicRegistration/ltiDynamicRegistration.schema.js';
|
|
7
|
+
import type { LTIMessage } from '../schemas/lti13/dynamicRegistration/ltiMessages.schema.js';
|
|
8
|
+
import {
|
|
9
|
+
type OpenIDConfiguration,
|
|
10
|
+
openIDConfigurationSchema,
|
|
11
|
+
} from '../schemas/lti13/dynamicRegistration/openIDConfiguration.schema.js';
|
|
12
|
+
import type { RegistrationRequest } from '../schemas/lti13/dynamicRegistration/registrationRequest.schema.js';
|
|
13
|
+
import type { RegistrationResponse } from '../schemas/lti13/dynamicRegistration/registrationResponse.schema.js';
|
|
14
|
+
import type { ToolRegistrationPayload } from '../schemas/lti13/dynamicRegistration/toolRegistrationPayload.schema.js';
|
|
15
|
+
|
|
16
|
+
import {
|
|
17
|
+
handleMoodleDynamicRegistration,
|
|
18
|
+
postRegistrationToMoodle,
|
|
19
|
+
} from './dynamicRegistrationHandlers/moodle.js';
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Service for handling LTI 1.3 dynamic registration workflows.
|
|
23
|
+
*
|
|
24
|
+
* Provides a complete implementation of the LTI 1.3 Dynamic Registration specification,
|
|
25
|
+
* enabling tools to automatically register with LTI platforms without manual configuration.
|
|
26
|
+
* Handles the full registration lifecycle from initiation to completion with security validation.
|
|
27
|
+
*
|
|
28
|
+
* ## Key Features
|
|
29
|
+
* - **Platform Discovery**: Fetches and validates OpenID Connect configuration from LTI platforms
|
|
30
|
+
* - **Security Validation**: Enforces hostname matching and session-based CSRF protection
|
|
31
|
+
* - **Vendor Support**: Provides platform-specific registration forms (Moodle, with extensibility for Canvas, Sakai, etc.)
|
|
32
|
+
* - **Service Selection**: Allows administrators to choose which LTI Advantage services to enable (AGS, NRPS, Deep Linking)
|
|
33
|
+
* - **Automatic Storage**: Persists client and deployment configurations for future launches
|
|
34
|
+
*
|
|
35
|
+
* ## Registration Flow
|
|
36
|
+
* 1. **Initiation**: Platform redirects to tool with registration request
|
|
37
|
+
* 2. **Discovery**: Tool fetches platform's OpenID Connect configuration
|
|
38
|
+
* 3. **Form Generation**: Tool presents service selection form to administrator
|
|
39
|
+
* 4. **Registration**: Tool submits registration payload to platform
|
|
40
|
+
* 5. **Storage**: Tool stores received client credentials and deployment information
|
|
41
|
+
*
|
|
42
|
+
* ## Security Features
|
|
43
|
+
* - Session-based registration with 15-minute expiration
|
|
44
|
+
* - CSRF protection via secure session tokens
|
|
45
|
+
* - Hostname validation between OIDC endpoint and issuer
|
|
46
|
+
* - One-time session consumption to prevent replay attacks
|
|
47
|
+
*
|
|
48
|
+
* @example
|
|
49
|
+
* ```typescript
|
|
50
|
+
* const service = new DynamicRegistrationService(
|
|
51
|
+
* storage,
|
|
52
|
+
* dynamicRegistrationConfig,
|
|
53
|
+
* logger
|
|
54
|
+
* );
|
|
55
|
+
*
|
|
56
|
+
* // Initiate registration
|
|
57
|
+
* const formHtml = await service.initiateDynamicRegistration(request, '/lti/register');
|
|
58
|
+
*
|
|
59
|
+
* // Complete registration
|
|
60
|
+
* const successHtml = await service.completeDynamicRegistration(formData);
|
|
61
|
+
* ```
|
|
62
|
+
*
|
|
63
|
+
* @see https://www.imsglobal.org/spec/lti-dr/v1p0 LTI 1.3 Dynamic Registration specification
|
|
64
|
+
*/
|
|
65
|
+
export class DynamicRegistrationService {
|
|
66
|
+
constructor(
|
|
67
|
+
private storage: LTIStorage,
|
|
68
|
+
private dynamicRegistrationConfig: DynamicRegistrationConfig,
|
|
69
|
+
private logger: BaseLogger,
|
|
70
|
+
) {}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Fetches and validates the OpenID Connect configuration from an LTI platform during dynamic registration.
|
|
74
|
+
* Validates that the OIDC endpoint and issuer have matching hostnames for security.
|
|
75
|
+
*
|
|
76
|
+
* @param registrationRequest - Registration request containing openid_configuration URL and optional registration_token
|
|
77
|
+
* @returns Validated OpenID configuration with platform endpoints and supported features
|
|
78
|
+
* @throws {Error} When the configuration fetch fails, validation fails, or hostname mismatch detected
|
|
79
|
+
*/
|
|
80
|
+
async fetchPlatformConfiguration(
|
|
81
|
+
registrationRequest: RegistrationRequest,
|
|
82
|
+
): Promise<OpenIDConfiguration> {
|
|
83
|
+
const { openid_configuration, registration_token } = registrationRequest;
|
|
84
|
+
const response = await fetch(openid_configuration, {
|
|
85
|
+
method: 'GET',
|
|
86
|
+
headers: {
|
|
87
|
+
// only include registration token if it was provided
|
|
88
|
+
...(registration_token && { Authorization: `Bearer ${registration_token}` }),
|
|
89
|
+
Accept: 'application/json',
|
|
90
|
+
},
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
await this.validateDynamicRegistrationResponse(
|
|
94
|
+
response,
|
|
95
|
+
'validateRegistrationRequest',
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
const data = await response.json();
|
|
99
|
+
const openIdConfiguration = openIDConfigurationSchema.parse(data);
|
|
100
|
+
this.logger.debug({ openIdConfiguration });
|
|
101
|
+
|
|
102
|
+
// validate that the endpoint and issuer have the same hostname
|
|
103
|
+
const oidcEndpoint = new URL(openid_configuration);
|
|
104
|
+
const { issuer } = openIdConfiguration;
|
|
105
|
+
const issuerEndpoint = new URL(issuer);
|
|
106
|
+
if (oidcEndpoint.hostname !== issuerEndpoint.hostname) {
|
|
107
|
+
const errorMessage = `OIDC endpoint and issuer in OIDC payload do not match, cannot continue. OIDC endpoint: ${oidcEndpoint} issuer endpoint: ${issuerEndpoint}`;
|
|
108
|
+
this.logger.error(errorMessage);
|
|
109
|
+
throw new Error(errorMessage);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// good to continue!
|
|
113
|
+
return openIdConfiguration;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Initiates LTI 1.3 dynamic registration by fetching platform configuration and generating registration form.
|
|
118
|
+
* Creates a temporary session and returns vendor-specific HTML form for service selection.
|
|
119
|
+
*
|
|
120
|
+
* @param registrationRequest - Registration request containing openid_configuration URL and optional registration_token
|
|
121
|
+
* @param requestPath - Current request path used to build form action URLs
|
|
122
|
+
* @returns HTML form for service selection and registration completion
|
|
123
|
+
* @throws {Error} When platform configuration fetch fails or session creation fails
|
|
124
|
+
*/
|
|
125
|
+
async initiateDynamicRegistration(
|
|
126
|
+
registrationRequest: RegistrationRequest,
|
|
127
|
+
requestPath: string,
|
|
128
|
+
): Promise<string> {
|
|
129
|
+
// 1. Validate request
|
|
130
|
+
const openIdConfiguration =
|
|
131
|
+
await this.fetchPlatformConfiguration(registrationRequest);
|
|
132
|
+
|
|
133
|
+
// 2. generate and store session
|
|
134
|
+
const sessionToken = crypto.randomUUID();
|
|
135
|
+
await this.storage.setRegistrationSession(sessionToken, {
|
|
136
|
+
openIdConfiguration,
|
|
137
|
+
registrationToken: registrationRequest.registration_token,
|
|
138
|
+
expiresAt: Date.now() + 15 * 60 * 1000, // 15 minutes
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
// 3. build form based on LMS vendor
|
|
142
|
+
const { product_family_code } =
|
|
143
|
+
openIdConfiguration['https://purl.imsglobal.org/spec/lti-platform-configuration'];
|
|
144
|
+
|
|
145
|
+
let html: string;
|
|
146
|
+
switch (product_family_code.toLowerCase()) {
|
|
147
|
+
case 'moodle':
|
|
148
|
+
html = handleMoodleDynamicRegistration(
|
|
149
|
+
openIdConfiguration,
|
|
150
|
+
requestPath,
|
|
151
|
+
sessionToken,
|
|
152
|
+
);
|
|
153
|
+
break;
|
|
154
|
+
default:
|
|
155
|
+
html = handleMoodleDynamicRegistration(
|
|
156
|
+
openIdConfiguration,
|
|
157
|
+
requestPath,
|
|
158
|
+
sessionToken,
|
|
159
|
+
);
|
|
160
|
+
break;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return html;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Completes LTI 1.3 dynamic registration by processing form submission and storing client configuration.
|
|
168
|
+
* Validates session, registers with platform, stores client/deployment data, and returns success page.
|
|
169
|
+
*
|
|
170
|
+
* @param dynamicRegistrationForm - Validated form data containing selected services and session token
|
|
171
|
+
* @returns HTML success page with registration details and close button
|
|
172
|
+
* @throws {Error} When session is invalid, registration fails, or storage operations fail
|
|
173
|
+
*/
|
|
174
|
+
async completeDynamicRegistration(
|
|
175
|
+
dynamicRegistrationForm: DynamicRegistrationForm,
|
|
176
|
+
): Promise<string> {
|
|
177
|
+
// 1. Verify session token
|
|
178
|
+
const session = await this.verifyRegistrationSession(
|
|
179
|
+
dynamicRegistrationForm.sessionToken,
|
|
180
|
+
);
|
|
181
|
+
if (!session) {
|
|
182
|
+
throw new Error('Invalid or expired session');
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// 1. build payload
|
|
186
|
+
const toolRegistrationPayload = this.buildRegistrationPayload(
|
|
187
|
+
dynamicRegistrationForm.services ?? [],
|
|
188
|
+
);
|
|
189
|
+
|
|
190
|
+
// 2. Post request to Moodle
|
|
191
|
+
const registrationResponse = await postRegistrationToMoodle(
|
|
192
|
+
session.openIdConfiguration.registration_endpoint,
|
|
193
|
+
toolRegistrationPayload,
|
|
194
|
+
this.logger,
|
|
195
|
+
session.registrationToken,
|
|
196
|
+
);
|
|
197
|
+
|
|
198
|
+
// 3. save to storage
|
|
199
|
+
const clientId = await this.storage.addClient({
|
|
200
|
+
name: registrationResponse.client_name,
|
|
201
|
+
clientId: registrationResponse.client_id,
|
|
202
|
+
iss: session.openIdConfiguration.issuer,
|
|
203
|
+
jwksUrl: session.openIdConfiguration.jwks_uri,
|
|
204
|
+
authUrl: session.openIdConfiguration.authorization_endpoint,
|
|
205
|
+
tokenUrl: session.openIdConfiguration.token_endpoint,
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
const ltiToolConfig =
|
|
209
|
+
registrationResponse['https://purl.imsglobal.org/spec/lti-tool-configuration'];
|
|
210
|
+
if (ltiToolConfig.deployment_id) {
|
|
211
|
+
await this.storage.addDeployment(clientId, {
|
|
212
|
+
deploymentId: ltiToolConfig.deployment_id,
|
|
213
|
+
name: 'Default Deployment via dynamic registration provided deployment id',
|
|
214
|
+
});
|
|
215
|
+
} else {
|
|
216
|
+
// platform doesn't use a deployment id (canvas for example) - create default
|
|
217
|
+
await this.storage.addDeployment(clientId, {
|
|
218
|
+
deploymentId: 'default',
|
|
219
|
+
name: 'Default Deployment (Canvas-style)',
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// 4. return success
|
|
224
|
+
const successHtml = this.getRegistrationSuccessHtml(registrationResponse);
|
|
225
|
+
return successHtml;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Verifies and consumes a registration session token for security validation.
|
|
230
|
+
* Retrieves the session data and immediately deletes it to prevent replay attacks.
|
|
231
|
+
*
|
|
232
|
+
* @param sessionToken - UUID session token from the registration form
|
|
233
|
+
* @returns Session data if valid and not expired, undefined otherwise
|
|
234
|
+
*/
|
|
235
|
+
async verifyRegistrationSession(
|
|
236
|
+
sessionToken: string,
|
|
237
|
+
): Promise<LTIDynamicRegistrationSession | undefined> {
|
|
238
|
+
const session = await this.storage.getRegistrationSession(sessionToken);
|
|
239
|
+
if (session) {
|
|
240
|
+
await this.storage.deleteRegistrationSession(sessionToken);
|
|
241
|
+
}
|
|
242
|
+
return session;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Builds array of LTI message types based on selected services during registration.
|
|
247
|
+
* Always includes ResourceLinkRequest, conditionally adds DeepLinkingRequest.
|
|
248
|
+
*
|
|
249
|
+
* @param selectedServices - Array of service names selected by administrator
|
|
250
|
+
* @param deepLinkingUri - URI where deep linking requests should be sent
|
|
251
|
+
* @returns Array of LTI message configurations for the registration payload
|
|
252
|
+
*/
|
|
253
|
+
private buildMessages(
|
|
254
|
+
selectedServices: string[],
|
|
255
|
+
deepLinkingUri: string,
|
|
256
|
+
): LTIMessage[] {
|
|
257
|
+
const messages = [];
|
|
258
|
+
messages.push({ type: 'LtiResourceLinkRequest' as const });
|
|
259
|
+
|
|
260
|
+
if (selectedServices?.includes('deep_linking')) {
|
|
261
|
+
messages.push({
|
|
262
|
+
type: 'LtiDeepLinkingRequest' as const,
|
|
263
|
+
target_link_uri: deepLinkingUri,
|
|
264
|
+
label: 'Content Selection',
|
|
265
|
+
placements: ['ContentArea' as const], // Focus on content area only
|
|
266
|
+
supported_types: ['ltiResourceLink' as const], // Standard content selection
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
return messages;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Builds array of OAuth scopes based on selected LTI services during registration.
|
|
275
|
+
* Maps service selections to their corresponding LTI Advantage scope URIs.
|
|
276
|
+
*
|
|
277
|
+
* @param selectedServices - Array of service names selected by administrator ('ags', 'nrps', etc.)
|
|
278
|
+
* @returns Array of OAuth scope URIs to request from the platform
|
|
279
|
+
*/
|
|
280
|
+
private buildScopes(selectedServices: string[]): string[] {
|
|
281
|
+
const scopes = [];
|
|
282
|
+
|
|
283
|
+
if (selectedServices?.includes('ags')) {
|
|
284
|
+
scopes.push(
|
|
285
|
+
'https://purl.imsglobal.org/spec/lti-ags/scope/lineitem',
|
|
286
|
+
'https://purl.imsglobal.org/spec/lti-ags/scope/result.readonly',
|
|
287
|
+
'https://purl.imsglobal.org/spec/lti-ags/scope/score',
|
|
288
|
+
);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
if (selectedServices?.includes('nrps')) {
|
|
292
|
+
scopes.push(
|
|
293
|
+
'https://purl.imsglobal.org/spec/lti-nrps/scope/contextmembership.readonly',
|
|
294
|
+
);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
return scopes;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Constructs the complete tool registration payload for platform submission.
|
|
302
|
+
* Combines tool configuration, selected services, and OAuth parameters into LTI 1.3 registration format.
|
|
303
|
+
*
|
|
304
|
+
* @param selectedServices - Array of service names selected by administrator
|
|
305
|
+
* @returns Complete registration payload ready for platform submission
|
|
306
|
+
*/
|
|
307
|
+
private buildRegistrationPayload(selectedServices: string[]): ToolRegistrationPayload {
|
|
308
|
+
const config = this.dynamicRegistrationConfig;
|
|
309
|
+
|
|
310
|
+
const deepLinkingUri = config.deepLinkingUri || `${config.url}/lti/deep-linking`;
|
|
311
|
+
const jwksUri = config.jwksUri || `${config.url}/lti/jwks`;
|
|
312
|
+
const launchUri = config.launchUri || `${config.url}/lti/launch`;
|
|
313
|
+
const loginUri = config.loginUri || `${config.url}/lti/login`;
|
|
314
|
+
|
|
315
|
+
const messages = this.buildMessages(selectedServices, deepLinkingUri);
|
|
316
|
+
const scopes = this.buildScopes(selectedServices);
|
|
317
|
+
|
|
318
|
+
const toolRegistrationPayload: ToolRegistrationPayload = {
|
|
319
|
+
application_type: 'web',
|
|
320
|
+
response_types: ['id_token'],
|
|
321
|
+
grant_types: ['implicit', 'client_credentials'],
|
|
322
|
+
initiate_login_uri: loginUri,
|
|
323
|
+
redirect_uris: [config.url, launchUri, ...(config.redirectUris || [])],
|
|
324
|
+
client_name: config.name,
|
|
325
|
+
jwks_uri: jwksUri,
|
|
326
|
+
logo_uri: config.logo,
|
|
327
|
+
scope: scopes.join(' '),
|
|
328
|
+
token_endpoint_auth_method: 'private_key_jwt',
|
|
329
|
+
'https://purl.imsglobal.org/spec/lti-tool-configuration': {
|
|
330
|
+
domain: new URL(config.url).hostname,
|
|
331
|
+
description: config.description,
|
|
332
|
+
target_link_uri: config.url,
|
|
333
|
+
claims: ['iss', 'sub', 'name', 'email'],
|
|
334
|
+
messages,
|
|
335
|
+
},
|
|
336
|
+
};
|
|
337
|
+
|
|
338
|
+
return toolRegistrationPayload;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
private async validateDynamicRegistrationResponse(
|
|
342
|
+
response: Response,
|
|
343
|
+
operation: string,
|
|
344
|
+
): Promise<void> {
|
|
345
|
+
if (!response.ok) {
|
|
346
|
+
const error = await response.json();
|
|
347
|
+
this.logger.error(
|
|
348
|
+
{ error, status: response.status, statusText: response.statusText },
|
|
349
|
+
`Dynamic Registration ${operation} failed`,
|
|
350
|
+
);
|
|
351
|
+
throw new Error(
|
|
352
|
+
`Dynamic Registration ${operation} failed: ${response.statusText} ${error}`,
|
|
353
|
+
);
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
private getRegistrationSuccessHtml(registrationResponse: RegistrationResponse): string {
|
|
358
|
+
return `
|
|
359
|
+
<!doctype html>
|
|
360
|
+
<html lang="en">
|
|
361
|
+
<head>
|
|
362
|
+
<meta charset="utf-8">
|
|
363
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
364
|
+
<title>Registration Complete</title>
|
|
365
|
+
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-sRIl4kxILFvY47J16cr9ZwB07vP4J8+LH7qKQnuqkuIAvNWLzeN8tE5YBujZqJLB" crossorigin="anonymous">
|
|
366
|
+
</head>
|
|
367
|
+
<body class="container mt-4">
|
|
368
|
+
<div class="alert alert-success" role="alert">
|
|
369
|
+
<h4 class="alert-heading">Registration Successful!</h4>
|
|
370
|
+
<p>Your LTI tool has been successfully registered with the platform.</p>
|
|
371
|
+
<hr>
|
|
372
|
+
<p class="mb-0">You can now close this window and return to your LMS.</p>
|
|
373
|
+
</div>
|
|
374
|
+
|
|
375
|
+
<div class="card">
|
|
376
|
+
<div class="card-header">
|
|
377
|
+
<h5 class="card-title mb-0">Registration Details</h5>
|
|
378
|
+
</div>
|
|
379
|
+
<div class="card-body">
|
|
380
|
+
<dl class="row">
|
|
381
|
+
<dt class="col-sm-3">Tool Name:</dt>
|
|
382
|
+
<dd class="col-sm-9">${registrationResponse.client_name}</dd>
|
|
383
|
+
<dt class="col-sm-3">Client ID:</dt>
|
|
384
|
+
<dd class="col-sm-9"><code>${registrationResponse.client_id}</code></dd>
|
|
385
|
+
<dt class="col-sm-3">Deployment ID:</dt>
|
|
386
|
+
<dd class="col-sm-9"><code>${registrationResponse['https://purl.imsglobal.org/spec/lti-tool-configuration'].deployment_id || 'default'}</code></dd>
|
|
387
|
+
</dl>
|
|
388
|
+
</div>
|
|
389
|
+
</div>
|
|
390
|
+
|
|
391
|
+
<div class="mt-4 text-center">
|
|
392
|
+
<button type="button" class="btn btn-primary btn-lg" onclick="closeWindow()">
|
|
393
|
+
Close Window
|
|
394
|
+
</button>
|
|
395
|
+
</div>
|
|
396
|
+
|
|
397
|
+
<script>
|
|
398
|
+
function closeWindow() {
|
|
399
|
+
(window.opener || window.parent).postMessage({subject:'org.imsglobal.lti.close'}, '*');
|
|
400
|
+
}
|
|
401
|
+
</script>
|
|
402
|
+
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/js/bootstrap.bundle.min.js" integrity="sha384-FKyoEForCGlyvwx9Hj09JcYn3nv7wiPVlz7YYwJrWVcXK/BmnVDxM+D2scQbITxI" crossorigin="anonymous"></script>
|
|
403
|
+
</body>
|
|
404
|
+
</html>`;
|
|
405
|
+
}
|
|
406
|
+
}
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import type { BaseLogger } from 'pino';
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
RegistrationResponseSchema,
|
|
5
|
+
type OpenIDConfiguration,
|
|
6
|
+
type RegistrationResponse,
|
|
7
|
+
} from '../../schemas';
|
|
8
|
+
import {
|
|
9
|
+
getAGSScopes,
|
|
10
|
+
hasAGSSupport,
|
|
11
|
+
hasDeepLinkingSupport,
|
|
12
|
+
hasNRPSSupport,
|
|
13
|
+
} from '../../utils/ltiPlatformCapabilities';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Generates Moodle-specific dynamic registration form HTML with service selection options.
|
|
17
|
+
* Creates a Bootstrap 5 form that detects available LTI Advantage services from the platform
|
|
18
|
+
* configuration and presents them as selectable checkboxes to the administrator.
|
|
19
|
+
*
|
|
20
|
+
* @param openIdConfiguration - Platform's OpenID Connect configuration containing supported services
|
|
21
|
+
* @param currentPath - Current request path used to build the form submission URL
|
|
22
|
+
* @param sessionToken - Security token for CSRF protection and session validation
|
|
23
|
+
* @returns Complete HTML page with Bootstrap form for service selection
|
|
24
|
+
*
|
|
25
|
+
* @example
|
|
26
|
+
* ```typescript
|
|
27
|
+
* const html = handleMoodleDynamicRegistration(
|
|
28
|
+
* platformConfig,
|
|
29
|
+
* '/lti/register',
|
|
30
|
+
* 'uuid-session-token'
|
|
31
|
+
* );
|
|
32
|
+
* // Returns HTML form with AGS, NRPS, and Deep Linking options if supported
|
|
33
|
+
* ```
|
|
34
|
+
*/
|
|
35
|
+
// oxlint-disable-next-line max-lines-per-function
|
|
36
|
+
export function handleMoodleDynamicRegistration(
|
|
37
|
+
openIdConfiguration: OpenIDConfiguration,
|
|
38
|
+
currentPath: string,
|
|
39
|
+
sessionToken: string,
|
|
40
|
+
): string {
|
|
41
|
+
const hasAGS = hasAGSSupport(openIdConfiguration);
|
|
42
|
+
const hasNRPS = hasNRPSSupport(openIdConfiguration);
|
|
43
|
+
const hasDeepLinking = hasDeepLinkingSupport(openIdConfiguration);
|
|
44
|
+
const agsScopes = getAGSScopes(openIdConfiguration);
|
|
45
|
+
// Build complete action from current path
|
|
46
|
+
const completeAction = `${currentPath}/complete`;
|
|
47
|
+
|
|
48
|
+
return `
|
|
49
|
+
<!doctype html>
|
|
50
|
+
<html lang="en">
|
|
51
|
+
<head>
|
|
52
|
+
<meta charset="utf-8">
|
|
53
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
54
|
+
<title>Configure LTI Advantage Settings for Moodle</title>
|
|
55
|
+
<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
|
+
</head>
|
|
57
|
+
<body class="container mt-4">
|
|
58
|
+
<form method="POST" action="${completeAction}">
|
|
59
|
+
<div class="mb-3">
|
|
60
|
+
<label class="form-label">Available Services</label>
|
|
61
|
+
${
|
|
62
|
+
hasAGS
|
|
63
|
+
? `
|
|
64
|
+
<div class="form-check">
|
|
65
|
+
<input class="form-check-input" type="checkbox" name="services" value="ags" id="ags" checked>
|
|
66
|
+
<label class="form-check-label" for="ags">
|
|
67
|
+
<strong>Assignment and Grade Services (AGS)</strong>
|
|
68
|
+
<small class="text-muted d-block">Enables automatic grade passback from this tool to your gradebook</small>
|
|
69
|
+
</label>
|
|
70
|
+
<div class="mt-2 ms-4">
|
|
71
|
+
<small class="text-muted">OAuth Scopes that will be requested:</small>
|
|
72
|
+
<pre class="bg-light p-2 mt-1 small border rounded">${agsScopes.map((scope) => scope.replace('https://purl.imsglobal.org/spec/lti-ags/scope/', '')).join('\n')}</pre>
|
|
73
|
+
</div>
|
|
74
|
+
</div>
|
|
75
|
+
`
|
|
76
|
+
: ''
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
${
|
|
80
|
+
hasNRPS
|
|
81
|
+
? `
|
|
82
|
+
<div class="form-check">
|
|
83
|
+
<input class="form-check-input" type="checkbox" name="services" value="nrps" id="nrps" checked>
|
|
84
|
+
<label class="form-check-label" for="nrps">Names and Role Provisioning Services (NRPS)</label>
|
|
85
|
+
</div>
|
|
86
|
+
`
|
|
87
|
+
: ''
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
${
|
|
91
|
+
hasDeepLinking
|
|
92
|
+
? `
|
|
93
|
+
<div class="form-check">
|
|
94
|
+
<input class="form-check-input" type="checkbox" name="services" value="deep_linking" id="deep_linking" checked>
|
|
95
|
+
<label class="form-check-label" for="deep_linking">Deep Linking</label>
|
|
96
|
+
</div>
|
|
97
|
+
`
|
|
98
|
+
: ''
|
|
99
|
+
}
|
|
100
|
+
</div>
|
|
101
|
+
<div class="mb-3">
|
|
102
|
+
<label class="form-label">Required Privacy Settings</label>
|
|
103
|
+
<div class="alert alert-info">
|
|
104
|
+
<small>These privacy settings must be enabled in your LMS for this tool to function properly.</small>
|
|
105
|
+
</div>
|
|
106
|
+
|
|
107
|
+
<div class="form-check">
|
|
108
|
+
<input class="form-check-input" type="checkbox" id="share_name" checked disabled>
|
|
109
|
+
<label class="form-check-label" for="share_name">
|
|
110
|
+
Share launcher's name
|
|
111
|
+
<small class="text-muted d-block">Required for user identification</small>
|
|
112
|
+
</label>
|
|
113
|
+
</div>
|
|
114
|
+
|
|
115
|
+
<div class="form-check">
|
|
116
|
+
<input class="form-check-input" type="checkbox" id="share_email" checked disabled>
|
|
117
|
+
<label class="form-check-label" for="share_email">
|
|
118
|
+
Share launcher's email
|
|
119
|
+
<small class="text-muted d-block">Required for user communication</small>
|
|
120
|
+
</label>
|
|
121
|
+
</div>
|
|
122
|
+
</div>
|
|
123
|
+
|
|
124
|
+
<input type="hidden" name="sessionToken" value="${sessionToken}">
|
|
125
|
+
|
|
126
|
+
<button type="submit" class="btn btn-primary">Register Tool</button>
|
|
127
|
+
</form>
|
|
128
|
+
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/js/bootstrap.bundle.min.js" integrity="sha384-FKyoEForCGlyvwx9Hj09JcYn3nv7wiPVlz7YYwJrWVcXK/BmnVDxM+D2scQbITxI" crossorigin="anonymous"></script>
|
|
129
|
+
</body>
|
|
130
|
+
</html>`;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Submits tool registration payload to Moodle's dynamic registration endpoint.
|
|
135
|
+
* Handles the HTTP communication with proper authentication, error handling, and response validation.
|
|
136
|
+
* Validates the registration response against the LTI 1.3 specification schema.
|
|
137
|
+
*
|
|
138
|
+
* @param registrationEndpoint - Platform's registration endpoint URL from OpenID configuration
|
|
139
|
+
* @param registrationPayload - Complete tool registration payload with OAuth and LTI configuration
|
|
140
|
+
* @param logger - Pino logger instance for request/response logging and error tracking
|
|
141
|
+
* @param registrationToken - Optional bearer token for authenticated registration requests
|
|
142
|
+
* @returns Validated registration response containing client credentials and deployment information
|
|
143
|
+
* @throws {Error} When registration request fails or response validation fails
|
|
144
|
+
*
|
|
145
|
+
* @example
|
|
146
|
+
* ```typescript
|
|
147
|
+
* const response = await postRegistrationToMoodle(
|
|
148
|
+
* 'https://moodle.edu/mod/lti/register.php',
|
|
149
|
+
* registrationPayload,
|
|
150
|
+
* logger,
|
|
151
|
+
* 'optional-bearer-token'
|
|
152
|
+
* );
|
|
153
|
+
* console.log('Registered with client ID:', response.client_id);
|
|
154
|
+
* ```
|
|
155
|
+
*/
|
|
156
|
+
export async function postRegistrationToMoodle(
|
|
157
|
+
registrationEndpoint: string,
|
|
158
|
+
registrationPayload: unknown,
|
|
159
|
+
logger: BaseLogger,
|
|
160
|
+
registrationToken?: string,
|
|
161
|
+
): Promise<RegistrationResponse> {
|
|
162
|
+
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
|
163
|
+
if (registrationToken) {
|
|
164
|
+
headers['Authorization'] = `Bearer ${registrationToken}`;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const response = await fetch(registrationEndpoint, {
|
|
168
|
+
method: 'POST',
|
|
169
|
+
headers,
|
|
170
|
+
body: JSON.stringify(registrationPayload),
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
if (!response.ok) {
|
|
174
|
+
const errorText = await response.json();
|
|
175
|
+
logger.error({ errorText }, 'lti dynamic registration error');
|
|
176
|
+
throw new Error(errorText);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const data = await response.json();
|
|
180
|
+
logger.debug({ data }, 'Registration response');
|
|
181
|
+
const validated = RegistrationResponseSchema.parse(data);
|
|
182
|
+
logger.debug({ validated }, 'Registration response validated');
|
|
183
|
+
return validated;
|
|
184
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import type { OpenIDConfiguration } from '../schemas';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Checks if an LTI platform supports Assignment and Grade Services (AGS).
|
|
5
|
+
* Examines the platform's OpenID configuration for AGS-related OAuth scopes.
|
|
6
|
+
*
|
|
7
|
+
* @param config - Platform's OpenID Connect configuration from discovery endpoint
|
|
8
|
+
* @returns True if the platform supports any AGS scopes, false otherwise
|
|
9
|
+
*
|
|
10
|
+
* @example
|
|
11
|
+
* ```typescript
|
|
12
|
+
* if (hasAGSSupport(platformConfig)) {
|
|
13
|
+
* // Show AGS checkbox in registration form
|
|
14
|
+
* // Enable grade passback functionality
|
|
15
|
+
* }
|
|
16
|
+
* ```
|
|
17
|
+
*/
|
|
18
|
+
export function hasAGSSupport(config: OpenIDConfiguration): boolean {
|
|
19
|
+
return (
|
|
20
|
+
config.scopes_supported?.some((scope) => scope.includes('lti-ags/scope')) ?? false
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Checks if an LTI platform supports Names and Role Provisioning Services (NRPS).
|
|
26
|
+
* Examines the platform's OpenID configuration for NRPS-related OAuth scopes.
|
|
27
|
+
*
|
|
28
|
+
* @param config - Platform's OpenID Connect configuration from discovery endpoint
|
|
29
|
+
* @returns True if the platform supports any NRPS scopes, false otherwise
|
|
30
|
+
*
|
|
31
|
+
* @example
|
|
32
|
+
* ```typescript
|
|
33
|
+
* if (hasNRPSSupport(platformConfig)) {
|
|
34
|
+
* // Show NRPS checkbox in registration form
|
|
35
|
+
* // Enable roster access functionality
|
|
36
|
+
* }
|
|
37
|
+
* ```
|
|
38
|
+
*/
|
|
39
|
+
export function hasNRPSSupport(config: OpenIDConfiguration): boolean {
|
|
40
|
+
return (
|
|
41
|
+
config.scopes_supported?.some((scope) => scope.includes('lti-nrps/scope')) ?? false
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Checks if an LTI platform supports Deep Linking for content selection.
|
|
47
|
+
* Examines the platform's LTI configuration for supported message types.
|
|
48
|
+
*
|
|
49
|
+
* @param config - Platform's OpenID Connect configuration from discovery endpoint
|
|
50
|
+
* @returns True if the platform supports LtiDeepLinkingRequest messages, false otherwise
|
|
51
|
+
*
|
|
52
|
+
* @example
|
|
53
|
+
* ```typescript
|
|
54
|
+
* if (hasDeepLinkingSupport(platformConfig)) {
|
|
55
|
+
* // Show Deep Linking checkbox in registration form
|
|
56
|
+
* // Enable content selection functionality
|
|
57
|
+
* }
|
|
58
|
+
* ```
|
|
59
|
+
*/
|
|
60
|
+
export function hasDeepLinkingSupport(config: OpenIDConfiguration): boolean {
|
|
61
|
+
const ltiConfig = config['https://purl.imsglobal.org/spec/lti-platform-configuration'];
|
|
62
|
+
return (
|
|
63
|
+
ltiConfig?.messages_supported?.some((msg) => msg.type === 'LtiDeepLinkingRequest') ??
|
|
64
|
+
false
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Extracts all Assignment and Grade Services (AGS) scopes supported by the platform.
|
|
70
|
+
* Filters the platform's supported scopes to return only AGS-related scope URIs.
|
|
71
|
+
*
|
|
72
|
+
* @param config - Platform's OpenID Connect configuration from discovery endpoint
|
|
73
|
+
* @returns Array of AGS scope URIs supported by the platform (e.g., lineitem, score, result.readonly)
|
|
74
|
+
*
|
|
75
|
+
* @example
|
|
76
|
+
* ```typescript
|
|
77
|
+
* const agsScopes = getAGSScopes(platformConfig);
|
|
78
|
+
* // Returns: ['https://purl.imsglobal.org/spec/lti-ags/scope/lineitem', ...]
|
|
79
|
+
* console.log('Available AGS scopes:', agsScopes.join(', '));
|
|
80
|
+
* ```
|
|
81
|
+
*/
|
|
82
|
+
export function getAGSScopes(config: OpenIDConfiguration): string[] {
|
|
83
|
+
return (
|
|
84
|
+
config.scopes_supported?.filter((scope) => scope.includes('lti-ags/scope')) ?? []
|
|
85
|
+
);
|
|
86
|
+
}
|