@lti-tool/core 0.11.1 → 0.12.1

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.
Files changed (59) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/dist/interfaces/index.d.ts +1 -0
  3. package/dist/interfaces/index.d.ts.map +1 -1
  4. package/dist/interfaces/ltiConfig.d.ts +23 -0
  5. package/dist/interfaces/ltiConfig.d.ts.map +1 -1
  6. package/dist/interfaces/ltiDynamicRegistrationSession.d.ts +14 -0
  7. package/dist/interfaces/ltiDynamicRegistrationSession.d.ts.map +1 -0
  8. package/dist/interfaces/ltiDynamicRegistrationSession.js +1 -0
  9. package/dist/interfaces/ltiStorage.d.ts +22 -0
  10. package/dist/interfaces/ltiStorage.d.ts.map +1 -1
  11. package/dist/ltiTool.d.ts +64 -1
  12. package/dist/ltiTool.d.ts.map +1 -1
  13. package/dist/ltiTool.js +87 -1
  14. package/dist/schemas/index.d.ts +5 -0
  15. package/dist/schemas/index.d.ts.map +1 -1
  16. package/dist/schemas/index.js +4 -0
  17. package/dist/schemas/lti13/dynamicRegistration/ltiDynamicRegistration.schema.d.ts +34 -0
  18. package/dist/schemas/lti13/dynamicRegistration/ltiDynamicRegistration.schema.d.ts.map +1 -0
  19. package/dist/schemas/lti13/dynamicRegistration/ltiDynamicRegistration.schema.js +28 -0
  20. package/dist/schemas/lti13/dynamicRegistration/ltiMessages.schema.d.ts +101 -0
  21. package/dist/schemas/lti13/dynamicRegistration/ltiMessages.schema.d.ts.map +1 -0
  22. package/dist/schemas/lti13/dynamicRegistration/ltiMessages.schema.js +51 -0
  23. package/dist/schemas/lti13/dynamicRegistration/openIDConfiguration.schema.d.ts +59 -0
  24. package/dist/schemas/lti13/dynamicRegistration/openIDConfiguration.schema.d.ts.map +1 -0
  25. package/dist/schemas/lti13/dynamicRegistration/openIDConfiguration.schema.js +53 -0
  26. package/dist/schemas/lti13/dynamicRegistration/registrationRequest.schema.d.ts +7 -0
  27. package/dist/schemas/lti13/dynamicRegistration/registrationRequest.schema.d.ts.map +1 -0
  28. package/dist/schemas/lti13/dynamicRegistration/registrationRequest.schema.js +5 -0
  29. package/dist/schemas/lti13/dynamicRegistration/registrationResponse.schema.d.ts +73 -0
  30. package/dist/schemas/lti13/dynamicRegistration/registrationResponse.schema.d.ts.map +1 -0
  31. package/dist/schemas/lti13/dynamicRegistration/registrationResponse.schema.js +61 -0
  32. package/dist/schemas/lti13/dynamicRegistration/toolRegistrationPayload.schema.d.ts +103 -0
  33. package/dist/schemas/lti13/dynamicRegistration/toolRegistrationPayload.schema.d.ts.map +1 -0
  34. package/dist/schemas/lti13/dynamicRegistration/toolRegistrationPayload.schema.js +49 -0
  35. package/dist/services/dynamicRegistration.service.d.ts +121 -0
  36. package/dist/services/dynamicRegistration.service.d.ts.map +1 -0
  37. package/dist/services/dynamicRegistration.service.js +312 -0
  38. package/dist/services/dynamicRegistrationHandlers/moodle.d.ts +48 -0
  39. package/dist/services/dynamicRegistrationHandlers/moodle.d.ts.map +1 -0
  40. package/dist/services/dynamicRegistrationHandlers/moodle.js +152 -0
  41. package/dist/utils/ltiPlatformCapabilities.d.ts +65 -0
  42. package/dist/utils/ltiPlatformCapabilities.d.ts.map +1 -0
  43. package/dist/utils/ltiPlatformCapabilities.js +73 -0
  44. package/package.json +2 -2
  45. package/src/interfaces/index.ts +1 -0
  46. package/src/interfaces/ltiConfig.ts +25 -0
  47. package/src/interfaces/ltiDynamicRegistrationSession.ts +14 -0
  48. package/src/interfaces/ltiStorage.ts +32 -0
  49. package/src/ltiTool.ts +122 -1
  50. package/src/schemas/index.ts +17 -0
  51. package/src/schemas/lti13/dynamicRegistration/ltiDynamicRegistration.schema.ts +31 -0
  52. package/src/schemas/lti13/dynamicRegistration/ltiMessages.schema.ts +59 -0
  53. package/src/schemas/lti13/dynamicRegistration/openIDConfiguration.schema.ts +60 -0
  54. package/src/schemas/lti13/dynamicRegistration/registrationRequest.schema.ts +8 -0
  55. package/src/schemas/lti13/dynamicRegistration/registrationResponse.schema.ts +67 -0
  56. package/src/schemas/lti13/dynamicRegistration/toolRegistrationPayload.schema.ts +55 -0
  57. package/src/services/dynamicRegistration.service.ts +406 -0
  58. package/src/services/dynamicRegistrationHandlers/moodle.ts +184 -0
  59. 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
+ }