@lti-tool/core 0.9.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 +15 -0
- package/README.md +60 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +3 -0
- package/dist/interfaces/index.d.ts +7 -0
- package/dist/interfaces/index.d.ts.map +1 -0
- package/dist/interfaces/index.js +1 -0
- package/dist/interfaces/jwks.d.ts +18 -0
- package/dist/interfaces/jwks.d.ts.map +1 -0
- package/dist/interfaces/jwks.js +1 -0
- package/dist/interfaces/ltiClient.d.ts +24 -0
- package/dist/interfaces/ltiClient.d.ts.map +1 -0
- package/dist/interfaces/ltiClient.js +1 -0
- package/dist/interfaces/ltiConfig.d.ts +26 -0
- package/dist/interfaces/ltiConfig.d.ts.map +1 -0
- package/dist/interfaces/ltiConfig.js +1 -0
- package/dist/interfaces/ltiDeployment.d.ts +15 -0
- package/dist/interfaces/ltiDeployment.d.ts.map +1 -0
- package/dist/interfaces/ltiDeployment.js +1 -0
- package/dist/interfaces/ltiLaunchConfig.d.ts +19 -0
- package/dist/interfaces/ltiLaunchConfig.d.ts.map +1 -0
- package/dist/interfaces/ltiLaunchConfig.js +1 -0
- package/dist/interfaces/ltiSession.d.ts +110 -0
- package/dist/interfaces/ltiSession.d.ts.map +1 -0
- package/dist/interfaces/ltiSession.js +1 -0
- package/dist/interfaces/ltiStorage.d.ts +122 -0
- package/dist/interfaces/ltiStorage.d.ts.map +1 -0
- package/dist/interfaces/ltiStorage.js +1 -0
- package/dist/ltiTool.d.ts +184 -0
- package/dist/ltiTool.d.ts.map +1 -0
- package/dist/ltiTool.js +305 -0
- package/dist/schemas/client.schema.d.ts +33 -0
- package/dist/schemas/client.schema.d.ts.map +1 -0
- package/dist/schemas/client.schema.js +14 -0
- package/dist/schemas/common.schema.d.ts +6 -0
- package/dist/schemas/common.schema.d.ts.map +1 -0
- package/dist/schemas/common.schema.js +5 -0
- package/dist/schemas/deployment.schema.d.ts +8 -0
- package/dist/schemas/deployment.schema.d.ts.map +1 -0
- package/dist/schemas/deployment.schema.js +11 -0
- package/dist/schemas/index.d.ts +5 -0
- package/dist/schemas/index.d.ts.map +1 -0
- package/dist/schemas/index.js +4 -0
- package/dist/schemas/lti13/ags/scoreSubmission.schema.d.ts +34 -0
- package/dist/schemas/lti13/ags/scoreSubmission.schema.d.ts.map +1 -0
- package/dist/schemas/lti13/ags/scoreSubmission.schema.js +41 -0
- package/dist/schemas/lti13/claims/baseJwtClaims.schema.d.ts +11 -0
- package/dist/schemas/lti13/claims/baseJwtClaims.schema.d.ts.map +1 -0
- package/dist/schemas/lti13/claims/baseJwtClaims.schema.js +10 -0
- package/dist/schemas/lti13/claims/contextClaims.schema.d.ts +11 -0
- package/dist/schemas/lti13/claims/contextClaims.schema.d.ts.map +1 -0
- package/dist/schemas/lti13/claims/contextClaims.schema.js +14 -0
- package/dist/schemas/lti13/claims/coreLtiClaims.schema.d.ts +9 -0
- package/dist/schemas/lti13/claims/coreLtiClaims.schema.d.ts.map +1 -0
- package/dist/schemas/lti13/claims/coreLtiClaims.schema.js +11 -0
- package/dist/schemas/lti13/claims/platformClaims.schema.d.ts +19 -0
- package/dist/schemas/lti13/claims/platformClaims.schema.d.ts.map +1 -0
- package/dist/schemas/lti13/claims/platformClaims.schema.js +24 -0
- package/dist/schemas/lti13/claims/privacyClaims.schema.d.ts +8 -0
- package/dist/schemas/lti13/claims/privacyClaims.schema.d.ts.map +1 -0
- package/dist/schemas/lti13/claims/privacyClaims.schema.js +7 -0
- package/dist/schemas/lti13/claims/serviceClaims.schema.d.ts +20 -0
- package/dist/schemas/lti13/claims/serviceClaims.schema.d.ts.map +1 -0
- package/dist/schemas/lti13/claims/serviceClaims.schema.js +25 -0
- package/dist/schemas/lti13/lti13JwtPayload.schema.d.ts +66 -0
- package/dist/schemas/lti13/lti13JwtPayload.schema.d.ts.map +1 -0
- package/dist/schemas/lti13/lti13JwtPayload.schema.js +22 -0
- package/dist/schemas/lti13/lti13Launch.schema.d.ts +14 -0
- package/dist/schemas/lti13/lti13Launch.schema.d.ts.map +1 -0
- package/dist/schemas/lti13/lti13Launch.schema.js +13 -0
- package/dist/schemas/lti13/lti13Login.schema.d.ts +23 -0
- package/dist/schemas/lti13/lti13Login.schema.d.ts.map +1 -0
- package/dist/schemas/lti13/lti13Login.schema.js +16 -0
- package/dist/services/ags.service.d.ts +38 -0
- package/dist/services/ags.service.d.ts.map +1 -0
- package/dist/services/ags.service.js +69 -0
- package/dist/services/session.service.d.ts +11 -0
- package/dist/services/session.service.d.ts.map +1 -0
- package/dist/services/session.service.js +103 -0
- package/dist/services/token.service.d.ts +36 -0
- package/dist/services/token.service.d.ts.map +1 -0
- package/dist/services/token.service.js +74 -0
- package/dist/utils/launchConfigValidation.d.ts +3 -0
- package/dist/utils/launchConfigValidation.d.ts.map +1 -0
- package/dist/utils/launchConfigValidation.js +7 -0
- package/package.json +53 -0
- package/src/index.ts +3 -0
- package/src/interfaces/index.ts +6 -0
- package/src/interfaces/jwks.ts +20 -0
- package/src/interfaces/ltiClient.ts +24 -0
- package/src/interfaces/ltiConfig.ts +31 -0
- package/src/interfaces/ltiDeployment.ts +17 -0
- package/src/interfaces/ltiLaunchConfig.ts +23 -0
- package/src/interfaces/ltiSession.ts +119 -0
- package/src/interfaces/ltiStorage.ts +161 -0
- package/src/ltiTool.ts +394 -0
- package/src/schemas/client.schema.ts +17 -0
- package/src/schemas/common.schema.ts +7 -0
- package/src/schemas/deployment.schema.ts +12 -0
- package/src/schemas/index.ts +10 -0
- package/src/schemas/lti13/ags/scoreSubmission.schema.ts +54 -0
- package/src/schemas/lti13/claims/baseJwtClaims.schema.ts +11 -0
- package/src/schemas/lti13/claims/contextClaims.schema.ts +16 -0
- package/src/schemas/lti13/claims/coreLtiClaims.schema.ts +12 -0
- package/src/schemas/lti13/claims/platformClaims.schema.ts +27 -0
- package/src/schemas/lti13/claims/privacyClaims.schema.ts +8 -0
- package/src/schemas/lti13/claims/serviceClaims.schema.ts +28 -0
- package/src/schemas/lti13/lti13JwtPayload.schema.ts +36 -0
- package/src/schemas/lti13/lti13Launch.schema.ts +15 -0
- package/src/schemas/lti13/lti13Login.schema.ts +18 -0
- package/src/services/ags.service.ts +92 -0
- package/src/services/session.service.ts +115 -0
- package/src/services/token.service.ts +84 -0
- package/src/utils/launchConfigValidation.ts +16 -0
- package/tsconfig.json +8 -0
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import type { LTIClient } from './ltiClient.js';
|
|
2
|
+
import type { LTIDeployment } from './ltiDeployment.js';
|
|
3
|
+
import type { LTILaunchConfig } from './ltiLaunchConfig.js';
|
|
4
|
+
import type { LTISession } from './ltiSession.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Storage interface for persisting LTI Client configurations, user sessions, and security nonces.
|
|
8
|
+
* Implement this interface to use different storage backends (memory, database, Redis, etc.).
|
|
9
|
+
*/
|
|
10
|
+
export interface LTIStorage {
|
|
11
|
+
// Client management
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Retrieves all clients configured in the system.
|
|
15
|
+
*
|
|
16
|
+
* @returns Array of all client configurations
|
|
17
|
+
*/
|
|
18
|
+
listClients(): Promise<Omit<LTIClient, 'deployments'>[]>;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Retrieves client configuration by its unique id.
|
|
22
|
+
*
|
|
23
|
+
* @param clientId - Unique client identifier
|
|
24
|
+
* @returns Client configuration if found, undefined otherwise
|
|
25
|
+
*/
|
|
26
|
+
getClientById(clientId: string): Promise<LTIClient | undefined>;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Adds a new client configuration to storage.
|
|
30
|
+
*
|
|
31
|
+
* @param client - Partial client configuration object
|
|
32
|
+
*/
|
|
33
|
+
addClient(client: Omit<LTIClient, 'id' | 'deployments'>): Promise<string>;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Updates an existing client configuration.
|
|
37
|
+
*
|
|
38
|
+
* @param clientId - Unique client identifier
|
|
39
|
+
* @param client - Partial client object with fields to update
|
|
40
|
+
*/
|
|
41
|
+
updateClient(
|
|
42
|
+
clientId: string,
|
|
43
|
+
client: Partial<Omit<LTIClient, 'id' | 'deployments'>>,
|
|
44
|
+
): Promise<void>;
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Removes a client configuration from storage.
|
|
48
|
+
*
|
|
49
|
+
* @param clientId - Unique client identifier
|
|
50
|
+
*/
|
|
51
|
+
deleteClient(clientId: string): Promise<void>;
|
|
52
|
+
|
|
53
|
+
// Deployment management
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Lists all deployments for a specific client.
|
|
57
|
+
*
|
|
58
|
+
* @param clientId - Client identifier
|
|
59
|
+
* @returns Array of deployment configurations
|
|
60
|
+
*/
|
|
61
|
+
listDeployments(clientId: string): Promise<LTIDeployment[]>;
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Retrieves deployment configuration by client ID and deployment ID (admin use).
|
|
65
|
+
*
|
|
66
|
+
* @param clientId - Unique client identifier
|
|
67
|
+
* @param deploymentId - Deployment identifier
|
|
68
|
+
* @returns Deployment configuration if found, undefined otherwise
|
|
69
|
+
*/
|
|
70
|
+
getDeployment(
|
|
71
|
+
clientId: string,
|
|
72
|
+
deploymentId: string,
|
|
73
|
+
): Promise<LTIDeployment | undefined>;
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Adds a new deployment to an existing client.
|
|
77
|
+
*
|
|
78
|
+
* @param clientId - Client identifier
|
|
79
|
+
* @param deployment - Deployment configuration to add
|
|
80
|
+
*/
|
|
81
|
+
addDeployment(clientId: string, deployment: Omit<LTIDeployment, 'id'>): Promise<string>;
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Updates an existing deployment configuration.
|
|
85
|
+
* @param clientId - Client identifier
|
|
86
|
+
* @param deploymentId - Deployment identifier to update
|
|
87
|
+
* @param deployment - Partial deployment object with fields to update
|
|
88
|
+
*/
|
|
89
|
+
updateDeployment(
|
|
90
|
+
clientId: string,
|
|
91
|
+
deploymentId: string,
|
|
92
|
+
deployment: Partial<LTIDeployment>,
|
|
93
|
+
): Promise<void>;
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Removes a deployment from a Client.
|
|
97
|
+
*
|
|
98
|
+
* @param clientId - Client identifier
|
|
99
|
+
* @param deploymentId - Deployment identifier to remove
|
|
100
|
+
*/
|
|
101
|
+
deleteDeployment(clientId: string, deploymentId: string): Promise<void>;
|
|
102
|
+
|
|
103
|
+
// Session management
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Retrieves an active user session by session ID.
|
|
107
|
+
*
|
|
108
|
+
* @param sessionId - Unique session identifier (typically a UUID)
|
|
109
|
+
* @returns Session object if found and valid, undefined otherwise
|
|
110
|
+
*/
|
|
111
|
+
getSession(sessionId: string): Promise<LTISession | undefined>;
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Stores a new user session after successful LTI launch.
|
|
115
|
+
*
|
|
116
|
+
* @param session - Complete session object with user, context, and service data
|
|
117
|
+
* @returns The session ID for reference
|
|
118
|
+
*/
|
|
119
|
+
addSession(session: LTISession): Promise<string>;
|
|
120
|
+
|
|
121
|
+
// Nonce validation (prevent replay attacks)
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Stores a nonce with expiration time for replay attack prevention.
|
|
125
|
+
*
|
|
126
|
+
* @param nonce - Unique nonce value (typically a UUID)
|
|
127
|
+
* @param expiresAt - When this nonce should be considered expired
|
|
128
|
+
*/
|
|
129
|
+
storeNonce(nonce: string, expiresAt: Date): Promise<void>;
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Validates a nonce and marks it as used to prevent replay attacks.
|
|
133
|
+
*
|
|
134
|
+
* @param nonce - Nonce value to validate
|
|
135
|
+
* @returns true if nonce is valid and unused, false if already used or expired
|
|
136
|
+
*/
|
|
137
|
+
validateNonce(nonce: string): Promise<boolean>;
|
|
138
|
+
|
|
139
|
+
// Launch configuration management
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Retrieves launch configuration for LTI authentication flow.
|
|
143
|
+
*
|
|
144
|
+
* @param iss - Platform issuer URL (identifies the LMS)
|
|
145
|
+
* @param clientId - OAuth2 client identifier for this tool
|
|
146
|
+
* @param deploymentId - Deployment identifier within the platform
|
|
147
|
+
* @returns Launch configuration if found, undefined otherwise
|
|
148
|
+
*/
|
|
149
|
+
getLaunchConfig(
|
|
150
|
+
iss: string,
|
|
151
|
+
clientId: string,
|
|
152
|
+
deploymentId: string,
|
|
153
|
+
): Promise<LTILaunchConfig | undefined>;
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Stores launch configuration for platform authentication.
|
|
157
|
+
*
|
|
158
|
+
* @param launchConfig - Complete launch configuration with auth URLs and keys
|
|
159
|
+
*/
|
|
160
|
+
saveLaunchConfig(launchConfig: LTILaunchConfig): Promise<void>;
|
|
161
|
+
}
|
package/src/ltiTool.ts
ADDED
|
@@ -0,0 +1,394 @@
|
|
|
1
|
+
import { SignJWT, createRemoteJWKSet, decodeJwt, exportJWK, jwtVerify } from 'jose';
|
|
2
|
+
import type { Logger } from 'pino';
|
|
3
|
+
|
|
4
|
+
import type { JWKS } from './interfaces/jwks.js';
|
|
5
|
+
import type { LTIClient } from './interfaces/ltiClient.js';
|
|
6
|
+
import type { LTIConfig } from './interfaces/ltiConfig.js';
|
|
7
|
+
import type { LTIDeployment } from './interfaces/ltiDeployment.js';
|
|
8
|
+
import type { LTISession } from './interfaces/ltiSession.js';
|
|
9
|
+
import { AddClientSchema, UpdateClientSchema } from './schemas/client.schema.js';
|
|
10
|
+
import {
|
|
11
|
+
HandleLoginParamsSchema,
|
|
12
|
+
type LTI13JwtPayload,
|
|
13
|
+
LTI13JwtPayloadSchema,
|
|
14
|
+
SessionIdSchema,
|
|
15
|
+
VerifyLaunchParamsSchema,
|
|
16
|
+
} from './schemas/index.js';
|
|
17
|
+
import type { ScoreSubmission } from './schemas/lti13/ags/scoreSubmission.schema.js';
|
|
18
|
+
import { AGSService } from './services/ags.service.js';
|
|
19
|
+
import { createSession } from './services/session.service.js';
|
|
20
|
+
import { TokenService } from './services/token.service.js';
|
|
21
|
+
import { getValidLaunchConfig } from './utils/launchConfigValidation.js';
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Main LTI 1.3 Tool implementation providing secure authentication, launch verification,
|
|
25
|
+
* and LTI Advantage services integration.
|
|
26
|
+
*
|
|
27
|
+
* @example
|
|
28
|
+
* ```typescript
|
|
29
|
+
* const ltiTool = new LTITool({
|
|
30
|
+
* stateSecret: new TextEncoder().encode('your-secret'),
|
|
31
|
+
* keyPair: await generateKeyPair('RS256'),
|
|
32
|
+
* storage: new MemoryStorage()
|
|
33
|
+
* });
|
|
34
|
+
*
|
|
35
|
+
* // Handle login initiation
|
|
36
|
+
* const authUrl = await ltiTool.handleLogin({
|
|
37
|
+
* client_id: 'your-client-id',
|
|
38
|
+
* iss: 'https://platform.example.com',
|
|
39
|
+
* launchUrl: 'https://yourtool.com/lti/launch',
|
|
40
|
+
* login_hint: 'user123',
|
|
41
|
+
* target_link_uri: 'https://yourtool.com/content',
|
|
42
|
+
* lti_deployment_id: 'deployment123'
|
|
43
|
+
* });
|
|
44
|
+
* ```
|
|
45
|
+
*/
|
|
46
|
+
export class LTITool {
|
|
47
|
+
/** Cache for JWKS remote key sets to improve performance */
|
|
48
|
+
private jwksCache = new Map<string, ReturnType<typeof createRemoteJWKSet>>();
|
|
49
|
+
private logger: Logger;
|
|
50
|
+
private tokenService: TokenService;
|
|
51
|
+
private agsService: AGSService;
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Creates a new LTI Tool instance.
|
|
55
|
+
*
|
|
56
|
+
* @param config - Configuration object containing secrets, keys, and storage adapter
|
|
57
|
+
*/
|
|
58
|
+
constructor(private config: LTIConfig) {
|
|
59
|
+
this.logger =
|
|
60
|
+
config.logger ??
|
|
61
|
+
({
|
|
62
|
+
debug: () => {},
|
|
63
|
+
info: () => {},
|
|
64
|
+
warn: () => {},
|
|
65
|
+
error: () => {},
|
|
66
|
+
} as unknown as Logger);
|
|
67
|
+
|
|
68
|
+
this.tokenService = new TokenService(
|
|
69
|
+
this.config.keyPair,
|
|
70
|
+
this.config.security?.keyId ?? 'main',
|
|
71
|
+
);
|
|
72
|
+
this.agsService = new AGSService(this.tokenService, this.config.storage, this.logger);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Handles LTI 1.3 login initiation by generating state/nonce and redirecting to platform auth.
|
|
77
|
+
*
|
|
78
|
+
* @param params - Login parameters from the platform
|
|
79
|
+
* @param params.client_id - OAuth2 client identifier for this tool
|
|
80
|
+
* @param params.iss - Platform issuer URL (identifies the LMS)
|
|
81
|
+
* @param params.launchUrl - URL where platform will POST the id_token after auth
|
|
82
|
+
* @param params.login_hint - Platform-specific user identifier hint
|
|
83
|
+
* @param params.target_link_uri - Final destination URL after successful launch
|
|
84
|
+
* @param params.lti_deployment_id - Deployment identifier within the platform
|
|
85
|
+
* @param params.lti_message_hint - Optional platform-specific message context
|
|
86
|
+
* @returns Authorization URL to redirect user to for authentication
|
|
87
|
+
* @throws {Error} When platform configuration is not found
|
|
88
|
+
*/
|
|
89
|
+
async handleLogin(params: {
|
|
90
|
+
client_id: string;
|
|
91
|
+
iss: string;
|
|
92
|
+
launchUrl: URL | string;
|
|
93
|
+
login_hint: string;
|
|
94
|
+
target_link_uri: string;
|
|
95
|
+
lti_deployment_id: string;
|
|
96
|
+
lti_message_hint?: string;
|
|
97
|
+
}): Promise<string> {
|
|
98
|
+
const validatedParams = HandleLoginParamsSchema.parse(params);
|
|
99
|
+
|
|
100
|
+
const nonce = crypto.randomUUID();
|
|
101
|
+
|
|
102
|
+
// Store nonce with expiration for replay attack prevention
|
|
103
|
+
const nonceExpirationSeconds = this.config.security?.nonceExpirationSeconds ?? 600;
|
|
104
|
+
const nonceExpiresAt = new Date(Date.now() + nonceExpirationSeconds * 1000);
|
|
105
|
+
await this.config.storage.storeNonce(nonce, nonceExpiresAt);
|
|
106
|
+
|
|
107
|
+
const state = await new SignJWT({
|
|
108
|
+
nonce,
|
|
109
|
+
iss: validatedParams.iss,
|
|
110
|
+
client_id: validatedParams.client_id,
|
|
111
|
+
target_link_uri: validatedParams.target_link_uri,
|
|
112
|
+
exp:
|
|
113
|
+
Math.floor(Date.now() / 1000) +
|
|
114
|
+
(this.config.security?.stateExpirationSeconds ?? 600),
|
|
115
|
+
})
|
|
116
|
+
.setProtectedHeader({ alg: 'HS256' })
|
|
117
|
+
.sign(this.config.stateSecret);
|
|
118
|
+
|
|
119
|
+
const launchConfig = await getValidLaunchConfig(
|
|
120
|
+
this.config.storage,
|
|
121
|
+
validatedParams.iss,
|
|
122
|
+
validatedParams.client_id,
|
|
123
|
+
validatedParams.lti_deployment_id,
|
|
124
|
+
);
|
|
125
|
+
|
|
126
|
+
const authUrl = new URL(launchConfig.authUrl);
|
|
127
|
+
authUrl.searchParams.set('scope', 'openid');
|
|
128
|
+
authUrl.searchParams.set('response_type', 'id_token');
|
|
129
|
+
authUrl.searchParams.set('response_mode', 'form_post');
|
|
130
|
+
authUrl.searchParams.set('prompt', 'none');
|
|
131
|
+
authUrl.searchParams.set('client_id', validatedParams.client_id);
|
|
132
|
+
authUrl.searchParams.set('redirect_uri', validatedParams.launchUrl.toString());
|
|
133
|
+
authUrl.searchParams.set('login_hint', validatedParams.login_hint);
|
|
134
|
+
authUrl.searchParams.set('state', state);
|
|
135
|
+
authUrl.searchParams.set('nonce', nonce);
|
|
136
|
+
authUrl.searchParams.set('lti_deployment_id', validatedParams.lti_deployment_id);
|
|
137
|
+
|
|
138
|
+
if (validatedParams.lti_message_hint) {
|
|
139
|
+
authUrl.searchParams.set('lti_message_hint', validatedParams.lti_message_hint);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return authUrl.toString();
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Verifies and validates an LTI 1.3 launch by checking JWT signatures, nonces, and claims.
|
|
147
|
+
*
|
|
148
|
+
* Performs comprehensive security validation including:
|
|
149
|
+
* - JWT signature verification using platform's JWKS
|
|
150
|
+
* - State JWT verification to prevent CSRF
|
|
151
|
+
* - Nonce validation to prevent replay attacks
|
|
152
|
+
* - Client ID and deployment ID verification
|
|
153
|
+
* - LTI 1.3 claim structure validation
|
|
154
|
+
*
|
|
155
|
+
* @param idToken - JWT id_token received from platform after authentication
|
|
156
|
+
* @param state - State JWT that was generated during login initiation
|
|
157
|
+
* @returns Validated and parsed LTI 1.3 JWT payload
|
|
158
|
+
* @throws {Error} When verification fails for security reasons
|
|
159
|
+
*/
|
|
160
|
+
async verifyLaunch(idToken: string, state: string): Promise<LTI13JwtPayload> {
|
|
161
|
+
const validatedParams = VerifyLaunchParamsSchema.parse({ idToken, state });
|
|
162
|
+
|
|
163
|
+
// 1. UNVERIFIED - get issuer
|
|
164
|
+
const unverified = decodeJwt(validatedParams.idToken);
|
|
165
|
+
if (!unverified.iss) {
|
|
166
|
+
throw new Error('No issuer in token');
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// 2. get the launchConfig so we can get the remote JWKS from our data store
|
|
170
|
+
const launchConfig = await getValidLaunchConfig(
|
|
171
|
+
this.config.storage,
|
|
172
|
+
unverified.iss,
|
|
173
|
+
unverified.aud as string,
|
|
174
|
+
unverified['https://purl.imsglobal.org/spec/lti/claim/deployment_id'] as string,
|
|
175
|
+
);
|
|
176
|
+
|
|
177
|
+
// 3. Verify LMS JWT
|
|
178
|
+
let jwks = this.jwksCache.get(launchConfig.jwksUrl);
|
|
179
|
+
if (!jwks) {
|
|
180
|
+
jwks = createRemoteJWKSet(new URL(launchConfig.jwksUrl));
|
|
181
|
+
this.jwksCache.set(launchConfig.jwksUrl, jwks);
|
|
182
|
+
}
|
|
183
|
+
const { payload } = await jwtVerify(validatedParams.idToken, jwks);
|
|
184
|
+
|
|
185
|
+
// 4. Verify our state JWT
|
|
186
|
+
const { payload: stateData } = await jwtVerify(
|
|
187
|
+
validatedParams.state,
|
|
188
|
+
this.config.stateSecret,
|
|
189
|
+
);
|
|
190
|
+
|
|
191
|
+
// 5. Parse and validate LMS JWT
|
|
192
|
+
const validated = LTI13JwtPayloadSchema.parse(payload);
|
|
193
|
+
|
|
194
|
+
// 6. Verify client id matches (audience claim)
|
|
195
|
+
if (validated.aud !== launchConfig.clientId) {
|
|
196
|
+
throw new Error(
|
|
197
|
+
`Invalid client_id: expected ${launchConfig.clientId}, got ${validated.aud}`,
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// 7. Verify nonce matches
|
|
202
|
+
if (stateData.nonce !== validated.nonce) {
|
|
203
|
+
throw new Error('Nonce mismatch');
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// 8. Check nonce hasn't been used before (prevent replay attacks)
|
|
207
|
+
const isValidNonce = await this.config.storage.validateNonce(validated.nonce);
|
|
208
|
+
if (!isValidNonce) {
|
|
209
|
+
throw new Error('Nonce has already been used or expired');
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
return validated;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Generates JSON Web Key Set (JWKS) containing the tool's public key for platform verification.
|
|
217
|
+
*
|
|
218
|
+
* @returns JWKS object with the tool's public key for JWT signature verification
|
|
219
|
+
*/
|
|
220
|
+
async getJWKS(): Promise<JWKS> {
|
|
221
|
+
const publicJwk = await exportJWK(this.config.keyPair.publicKey);
|
|
222
|
+
return {
|
|
223
|
+
keys: [
|
|
224
|
+
{
|
|
225
|
+
...publicJwk,
|
|
226
|
+
use: 'sig',
|
|
227
|
+
alg: 'RS256',
|
|
228
|
+
kid: this.config.security?.keyId ?? 'main',
|
|
229
|
+
},
|
|
230
|
+
],
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Creates and stores a new LTI session from validated JWT payload.
|
|
236
|
+
*
|
|
237
|
+
* @param lti13JwtPayload - Validated LTI 1.3 JWT payload from successful launch
|
|
238
|
+
* @returns Created session object with user, context, and service information
|
|
239
|
+
*/
|
|
240
|
+
async createSession(lti13JwtPayload: LTI13JwtPayload): Promise<LTISession> {
|
|
241
|
+
const session = createSession(lti13JwtPayload);
|
|
242
|
+
await this.config.storage.addSession(session);
|
|
243
|
+
return session;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Retrieves an existing LTI session by session ID.
|
|
248
|
+
*
|
|
249
|
+
* @param sessionId - Unique session identifier
|
|
250
|
+
* @returns Session object if found, undefined otherwise
|
|
251
|
+
*/
|
|
252
|
+
async getSession(sessionId: string): Promise<LTISession | undefined> {
|
|
253
|
+
const validatedSessionId = SessionIdSchema.parse(sessionId);
|
|
254
|
+
return await this.config.storage.getSession(validatedSessionId);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Submits a grade score to the platform using Assignment and Grade Services (AGS).
|
|
259
|
+
*
|
|
260
|
+
* @param session - Active LTI session containing AGS service endpoints
|
|
261
|
+
* @param score - Score submission data including grade value and user ID
|
|
262
|
+
* @returns Result of the score submission
|
|
263
|
+
* @throws {Error} When AGS is not available or submission fails
|
|
264
|
+
*/
|
|
265
|
+
async submitScore(session: LTISession, score: ScoreSubmission): Promise<Response> {
|
|
266
|
+
if (!session) {
|
|
267
|
+
throw new Error('session is required');
|
|
268
|
+
}
|
|
269
|
+
if (!score) {
|
|
270
|
+
throw new Error('score is required');
|
|
271
|
+
}
|
|
272
|
+
return await this.agsService.submitScore(session, score);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// Client management
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Retrieves all configured LTI client platforms.
|
|
279
|
+
*
|
|
280
|
+
* @returns Array of client configurations (without deployment details)
|
|
281
|
+
*/
|
|
282
|
+
async listClients(): Promise<Omit<LTIClient, 'deployments'>[]> {
|
|
283
|
+
return await this.config.storage.listClients();
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Updates an existing client configuration.
|
|
288
|
+
*
|
|
289
|
+
* @param clientId - Unique client identifier
|
|
290
|
+
* @param client - Partial client object with fields to update
|
|
291
|
+
*/
|
|
292
|
+
async updateClient(
|
|
293
|
+
clientId: string,
|
|
294
|
+
client: Partial<Omit<LTIClient, 'id' | 'deployments'>>,
|
|
295
|
+
): Promise<void> {
|
|
296
|
+
const validated = UpdateClientSchema.parse(client);
|
|
297
|
+
return await this.config.storage.updateClient(clientId, validated);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Retrieves a specific client configuration by ID.
|
|
302
|
+
*
|
|
303
|
+
* @param clientId - Unique client identifier
|
|
304
|
+
* @returns Client configuration if found, undefined otherwise
|
|
305
|
+
*/
|
|
306
|
+
async getClientById(clientId: string): Promise<LTIClient | undefined> {
|
|
307
|
+
return await this.config.storage.getClientById(clientId);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* Adds a new LTI client platform configuration.
|
|
312
|
+
*
|
|
313
|
+
* @param client - Client configuration (ID will be auto-generated)
|
|
314
|
+
* @returns The generated client ID
|
|
315
|
+
*/
|
|
316
|
+
async addClient(client: Omit<LTIClient, 'id' | 'deployments'>): Promise<string> {
|
|
317
|
+
const validated = AddClientSchema.parse(client);
|
|
318
|
+
return await this.config.storage.addClient(validated);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* Removes a client configuration and all its deployments.
|
|
323
|
+
*
|
|
324
|
+
* @param clientId - Unique client identifier
|
|
325
|
+
*/
|
|
326
|
+
async deleteClient(clientId: string): Promise<void> {
|
|
327
|
+
return await this.config.storage.deleteClient(clientId);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// Deployment management
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* Lists all deployments for a specific client platform.
|
|
334
|
+
*
|
|
335
|
+
* @param clientId - Client identifier
|
|
336
|
+
* @returns Array of deployment configurations for the client
|
|
337
|
+
*/
|
|
338
|
+
async listDeployments(clientId: string): Promise<LTIDeployment[]> {
|
|
339
|
+
return await this.config.storage.listDeployments(clientId);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* Retrieves a specific deployment configuration.
|
|
344
|
+
*
|
|
345
|
+
* @param clientId - Client identifier
|
|
346
|
+
* @param deploymentId - Deployment identifier
|
|
347
|
+
* @returns Deployment configuration if found, undefined otherwise
|
|
348
|
+
*/
|
|
349
|
+
async getDeployment(
|
|
350
|
+
clientId: string,
|
|
351
|
+
deploymentId: string,
|
|
352
|
+
): Promise<LTIDeployment | undefined> {
|
|
353
|
+
return await this.config.storage.getDeployment(clientId, deploymentId);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* Adds a new deployment to an existing client.
|
|
358
|
+
*
|
|
359
|
+
* @param clientId - Client identifier
|
|
360
|
+
* @param deployment - Deployment configuration to add
|
|
361
|
+
* @returns The generated deployment ID
|
|
362
|
+
*/
|
|
363
|
+
async addDeployment(
|
|
364
|
+
clientId: string,
|
|
365
|
+
deployment: Omit<LTIDeployment, 'id'>,
|
|
366
|
+
): Promise<string> {
|
|
367
|
+
return await this.config.storage.addDeployment(clientId, deployment);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* Updates an existing deployment configuration.
|
|
372
|
+
*
|
|
373
|
+
* @param clientId - Client identifier
|
|
374
|
+
* @param deploymentId - Deployment identifier
|
|
375
|
+
* @param deployment - Partial deployment object with fields to update
|
|
376
|
+
*/
|
|
377
|
+
async updateDeployment(
|
|
378
|
+
clientId: string,
|
|
379
|
+
deploymentId: string,
|
|
380
|
+
deployment: Partial<LTIDeployment>,
|
|
381
|
+
): Promise<void> {
|
|
382
|
+
return await this.config.storage.updateDeployment(clientId, deploymentId, deployment);
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* Removes a deployment from a client.
|
|
387
|
+
*
|
|
388
|
+
* @param clientId - Client identifier
|
|
389
|
+
* @param deploymentId - Deployment identifier to remove
|
|
390
|
+
*/
|
|
391
|
+
async deleteDeployment(clientId: string, deploymentId: string): Promise<void> {
|
|
392
|
+
return await this.config.storage.deleteDeployment(clientId, deploymentId);
|
|
393
|
+
}
|
|
394
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
|
|
3
|
+
import { DeploymentSchema } from './deployment.schema';
|
|
4
|
+
|
|
5
|
+
export const ClientSchema = z.object({
|
|
6
|
+
id: z.uuid().describe('Internal stable UUID for the client'),
|
|
7
|
+
name: z.string().min(1).describe('human-readable name for the platform'),
|
|
8
|
+
iss: z.url().describe('Platform issuer (unique identifier)'),
|
|
9
|
+
clientId: z.string().min(1).describe("Your app's client ID on this platform"),
|
|
10
|
+
authUrl: z.url().describe("Platform's auth endpoint"),
|
|
11
|
+
tokenUrl: z.url().describe("Platform's token endpoint"),
|
|
12
|
+
jwksUrl: z.url().describe("Platform's JWKS endpoint"),
|
|
13
|
+
deployments: z.array(DeploymentSchema),
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
export const AddClientSchema = ClientSchema.omit({ id: true, deployments: true });
|
|
17
|
+
export const UpdateClientSchema = ClientSchema.omit({ id: true, deployments: true });
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
|
|
3
|
+
export const DeploymentSchema = z.object({
|
|
4
|
+
id: z.uuid().describe('Internal stable UUID for this deployment configuration'),
|
|
5
|
+
deploymentId: z.string().min(1).describe('LMS-provided deployment identifier'),
|
|
6
|
+
name: z
|
|
7
|
+
.string()
|
|
8
|
+
.min(1)
|
|
9
|
+
.optional()
|
|
10
|
+
.describe('Optional human-readable name for the deployment'),
|
|
11
|
+
description: z.string().optional().describe('Optional description of the deployment'),
|
|
12
|
+
});
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export { SessionIdSchema } from './common.schema.js';
|
|
2
|
+
export {
|
|
3
|
+
LTI13JwtPayloadSchema,
|
|
4
|
+
type LTI13JwtPayload,
|
|
5
|
+
} from './lti13/lti13JwtPayload.schema.js';
|
|
6
|
+
export {
|
|
7
|
+
LTI13LaunchSchema,
|
|
8
|
+
VerifyLaunchParamsSchema,
|
|
9
|
+
} from './lti13/lti13Launch.schema.js';
|
|
10
|
+
export { HandleLoginParamsSchema, LTI13LoginSchema } from './lti13/lti13Login.schema.js';
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Schema for submitting grades via LTI Assignment and Grade Services (AGS).
|
|
5
|
+
* Validates score data according to LTI AGS v2.0 specification.
|
|
6
|
+
*
|
|
7
|
+
* @see https://www.imsglobal.org/spec/lti-ags/v2p0/#score-publish-service
|
|
8
|
+
*/
|
|
9
|
+
export const ScoreSubmissionSchema = z.object({
|
|
10
|
+
/** Points awarded to the student (must be non-negative) */
|
|
11
|
+
scoreGiven: z.number().min(0),
|
|
12
|
+
|
|
13
|
+
/** Maximum possible points for this assignment (must be non-negative) */
|
|
14
|
+
scoreMaximum: z.number().min(0),
|
|
15
|
+
|
|
16
|
+
/** Optional feedback comment to display to the student */
|
|
17
|
+
comment: z.string().optional(),
|
|
18
|
+
|
|
19
|
+
/** User ID to submit score for (optional - defaults to session user if not provided) */
|
|
20
|
+
userId: z.string().optional(),
|
|
21
|
+
|
|
22
|
+
/** Timestamp when score was generated (optional - defaults to current time) */
|
|
23
|
+
timestamp: z.iso.datetime().optional(),
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Student's progress on the activity itself.
|
|
27
|
+
* - Initialized: Student has started but not made progress
|
|
28
|
+
* - Started: Student has begun working
|
|
29
|
+
* - InProgress: Student is actively working
|
|
30
|
+
* - Submitted: Student has submitted work for review
|
|
31
|
+
* - Completed: Student has finished the activity
|
|
32
|
+
*/
|
|
33
|
+
activityProgress: z
|
|
34
|
+
.enum(['Initialized', 'Started', 'InProgress', 'Submitted', 'Completed'])
|
|
35
|
+
.default('Completed'),
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Instructor's progress on grading the submission.
|
|
39
|
+
* - NotReady: Submission not ready for grading
|
|
40
|
+
* - Failed: Grading failed due to error
|
|
41
|
+
* - Pending: Awaiting automatic grading
|
|
42
|
+
* - PendingManual: Awaiting manual grading
|
|
43
|
+
* - FullyGraded: Grading is complete
|
|
44
|
+
*/
|
|
45
|
+
gradingProgress: z
|
|
46
|
+
.enum(['NotReady', 'Failed', 'Pending', 'PendingManual', 'FullyGraded'])
|
|
47
|
+
.default('FullyGraded'),
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Type representing a validated score submission for LTI AGS.
|
|
52
|
+
* Contains grade data and metadata to be sent to the platform.
|
|
53
|
+
*/
|
|
54
|
+
export type ScoreSubmission = z.infer<typeof ScoreSubmissionSchema>;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
|
|
3
|
+
export const BaseJwtClaimsSchema = z.object({
|
|
4
|
+
iss: z.string(),
|
|
5
|
+
sub: z.string(),
|
|
6
|
+
aud: z.union([z.string(), z.array(z.string())]),
|
|
7
|
+
exp: z.number(),
|
|
8
|
+
iat: z.number(),
|
|
9
|
+
nbf: z.number().optional(),
|
|
10
|
+
nonce: z.string(),
|
|
11
|
+
});
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
|
|
3
|
+
export const ResourceLinkSchema = z
|
|
4
|
+
.object({
|
|
5
|
+
id: z.string(),
|
|
6
|
+
title: z.string().optional(),
|
|
7
|
+
})
|
|
8
|
+
.optional();
|
|
9
|
+
|
|
10
|
+
export const ContextSchema = z
|
|
11
|
+
.object({
|
|
12
|
+
id: z.string(),
|
|
13
|
+
label: z.string().optional(),
|
|
14
|
+
title: z.string().optional(),
|
|
15
|
+
})
|
|
16
|
+
.optional();
|