forked from 1EdTech/lti-1-3-php-library
-
Notifications
You must be signed in to change notification settings - Fork 23
/
LtiMessageLaunch.php
499 lines (430 loc) · 17 KB
/
LtiMessageLaunch.php
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
<?php
namespace Packback\Lti1p3;
use Firebase\JWT\ExpiredException;
use Firebase\JWT\JWK;
use Firebase\JWT\JWT;
use GuzzleHttp\Client;
use GuzzleHttp\Exception\TransferException;
use Packback\Lti1p3\Interfaces\ICache;
use Packback\Lti1p3\Interfaces\ICookie;
use Packback\Lti1p3\Interfaces\IDatabase;
use Packback\Lti1p3\Interfaces\ILtiServiceConnector;
use Packback\Lti1p3\MessageValidators\DeepLinkMessageValidator;
use Packback\Lti1p3\MessageValidators\ResourceMessageValidator;
use Packback\Lti1p3\MessageValidators\SubmissionReviewMessageValidator;
class LtiMessageLaunch
{
public const TYPE_DEEPLINK = 'LtiDeepLinkingRequest';
public const TYPE_SUBMISSIONREVIEW = 'LtiSubmissionReviewRequest';
public const TYPE_RESOURCELINK = 'LtiResourceLinkRequest';
public const ERR_FETCH_PUBLIC_KEY = 'Failed to fetch public key.';
public const ERR_NO_PUBLIC_KEY = 'Unable to find public key.';
public const ERR_STATE_NOT_FOUND = 'State not found. Please make sure you have cookies enabled in this browser.';
public const ERR_MISSING_ID_TOKEN = 'Missing id_token.';
public const ERR_INVALID_ID_TOKEN = 'Invalid id_token, JWT must contain 3 parts';
public const ERR_MISSING_NONCE = 'Missing Nonce.';
public const ERR_INVALID_NONCE = 'Invalid Nonce.';
public const ERR_MISSING_REGISTRATION = 'Registration not found. Please have your admin confirm your Issuer URL, client ID, and deployment ID.';
public const ERR_CLIENT_NOT_REGISTERED = 'Client id not registered for this issuer.';
public const ERR_NO_KID = 'No KID specified in the JWT Header.';
public const ERR_INVALID_SIGNATURE = 'Invalid signature on id_token';
public const ERR_MISSING_DEPLOYEMENT_ID = 'No deployment ID was specified';
public const ERR_NO_DEPLOYMENT = 'Unable to find deployment.';
public const ERR_INVALID_MESSAGE_TYPE = 'Invalid message type';
public const ERR_VALIDATOR_CONFLICT = 'Validator conflict.';
public const ERR_UNRECOGNIZED_MESSAGE_TYPE = 'Unrecognized message type.';
public const ERR_INVALID_MESSAGE = 'Message validation failed.';
public const ERR_INVALID_ALG = 'Invalid alg was specified in the JWT header.';
public const ERR_MISMATCHED_ALG_KEY = 'The alg specified in the JWT header is incompatible with the JWK key type.';
private $db;
private $cache;
private $cookie;
private $serviceConnector;
private $request;
private $jwt;
private $registration;
private $launch_id;
// See https://www.imsglobal.org/spec/security/v1p1#approved-jwt-signing-algorithms.
private static $ltiSupportedAlgs = [
'RS256' => 'RSA',
'RS384' => 'RSA',
'RS512' => 'RSA',
'ES256' => 'EC',
'ES384' => 'EC',
'ES512' => 'EC',
];
/**
* Constructor.
*
* @param IDatabase $database instance of the database interface used for looking up registrations and deployments
* @param ICache $cache instance of the Cache interface used to loading and storing launches
* @param ICookie $cookie instance of the Cookie interface used to set and read cookies
* @param ILtiServiceConnector $serviceConnector instance of the LtiServiceConnector used to by LTI services to make API requests
*/
public function __construct(
IDatabase $database,
ICache $cache = null,
ICookie $cookie = null,
ILtiServiceConnector $serviceConnector = null
) {
$this->db = $database;
$this->launch_id = uniqid('lti1p3_launch_', true);
$this->cache = $cache;
$this->cookie = $cookie;
$this->serviceConnector = $serviceConnector;
}
/**
* Static function to allow for method chaining without having to assign to a variable first.
*/
public static function new(
IDatabase $database,
ICache $cache = null,
ICookie $cookie = null,
ILtiServiceConnector $serviceConnector = null
) {
return new LtiMessageLaunch($database, $cache, $cookie, $serviceConnector);
}
/**
* Load an LtiMessageLaunch from a Cache using a launch id.
*
* @param string $launch_id the launch id of the LtiMessageLaunch object that is being pulled from the cache
* @param IDatabase $database instance of the database interface used for looking up registrations and deployments
* @param ICache $cache Instance of the Cache interface used to loading and storing launches. If non is provided launch data will be store in $_SESSION.
*
* @throws LtiException will throw an LtiException if validation fails or launch cannot be found
*
* @return LtiMessageLaunch a populated and validated LtiMessageLaunch
*/
public static function fromCache($launch_id,
IDatabase $database,
ICache $cache = null,
ILtiServiceConnector $serviceConnector = null)
{
$new = new LtiMessageLaunch($database, $cache, null, $serviceConnector);
$new->launch_id = $launch_id;
$new->jwt = ['body' => $new->cache->getLaunchData($launch_id)];
return $new->validateRegistration();
}
/**
* Validates all aspects of an incoming LTI message launch and caches the launch if successful.
*
* @param array|string $request An array of post request parameters. If not set will default to $_POST.
*
* @throws LtiException will throw an LtiException if validation fails
*
* @return LtiMessageLaunch will return $this if validation is successful
*/
public function validate(array $request = null)
{
if ($request === null) {
$request = $_POST;
}
$this->request = $request;
return $this->validateState()
->validateJwtFormat()
->validateNonce()
->validateRegistration()
->validateJwtSignature()
->validateDeployment()
->validateMessage()
->cacheLaunchData();
}
/**
* Returns whether or not the current launch can use the names and roles service.
*
* @return bool returns a boolean indicating the availability of names and roles
*/
public function hasNrps()
{
return !empty($this->jwt['body'][LtiConstants::NRPS_CLAIM_SERVICE]['context_memberships_url']);
}
/**
* Fetches an instance of the names and roles service for the current launch.
*
* @return LtiNamesRolesProvisioningService an instance of the names and roles service that can be used to make calls within the scope of the current launch
*/
public function getNrps()
{
return new LtiNamesRolesProvisioningService(
$this->serviceConnector,
$this->registration,
$this->jwt['body'][LtiConstants::NRPS_CLAIM_SERVICE]);
}
/**
* Returns whether or not the current launch can use the groups service.
*
* @return bool returns a boolean indicating the availability of groups
*/
public function hasGs()
{
return !empty($this->jwt['body'][LtiConstants::GS_CLAIM_SERVICE]['context_groups_url']);
}
/**
* Fetches an instance of the groups service for the current launch.
*
* @return LtiCourseGroupsService an instance of the groups service that can be used to make calls within the scope of the current launch
*/
public function getGs()
{
return new LtiCourseGroupsService(
$this->serviceConnector,
$this->registration,
$this->jwt['body'][LtiConstants::GS_CLAIM_SERVICE]);
}
/**
* Returns whether or not the current launch can use the assignments and grades service.
*
* @return bool returns a boolean indicating the availability of assignments and grades
*/
public function hasAgs()
{
return !empty($this->jwt['body'][LtiConstants::AGS_CLAIM_ENDPOINT]);
}
/**
* Fetches an instance of the assignments and grades service for the current launch.
*
* @return LtiAssignmentsGradesService an instance of the assignments an grades service that can be used to make calls within the scope of the current launch
*/
public function getAgs()
{
return new LtiAssignmentsGradesService(
$this->serviceConnector,
$this->registration,
$this->jwt['body'][LtiConstants::AGS_CLAIM_ENDPOINT]);
}
/**
* Returns whether or not the current launch is a deep linking launch.
*
* @return bool returns true if the current launch is a deep linking launch
*/
public function isDeepLinkLaunch()
{
return $this->jwt['body'][LtiConstants::MESSAGE_TYPE] === static::TYPE_DEEPLINK;
}
/**
* Fetches a deep link that can be used to construct a deep linking response.
*
* @return LtiDeepLink an instance of a deep link to construct a deep linking response for the current launch
*/
public function getDeepLink()
{
return new LtiDeepLink(
$this->registration,
$this->jwt['body'][LtiConstants::DEPLOYMENT_ID],
$this->jwt['body'][LtiConstants::DL_DEEP_LINK_SETTINGS]);
}
/**
* Returns whether or not the current launch is a submission review launch.
*
* @return bool returns true if the current launch is a submission review launch
*/
public function isSubmissionReviewLaunch()
{
return $this->jwt['body'][LtiConstants::MESSAGE_TYPE] === static::TYPE_SUBMISSIONREVIEW;
}
/**
* Returns whether or not the current launch is a resource launch.
*
* @return bool returns true if the current launch is a resource launch
*/
public function isResourceLaunch()
{
return $this->jwt['body'][LtiConstants::MESSAGE_TYPE] === static::TYPE_RESOURCELINK;
}
/**
* Fetches the decoded body of the JWT used in the current launch.
*
* @return array|object returns the decoded json body of the launch as an array
*/
public function getLaunchData()
{
return $this->jwt['body'];
}
/**
* Get the unique launch id for the current launch.
*
* @return string a unique identifier used to re-reference the current launch in subsequent requests
*/
public function getLaunchId()
{
return $this->launch_id;
}
private function getPublicKey()
{
$request = new ServiceRequest(
ServiceRequest::METHOD_GET,
$this->registration->getKeySetUrl(),
ServiceRequest::TYPE_GET_KEYSET
);
// Download key set
try {
$response = $this->serviceConnector->makeRequest($request);
} catch (TransferException $e) {
throw new LtiException(static::ERR_NO_PUBLIC_KEY);
}
$publicKeySet = $this->serviceConnector->getResponseBody($response);
if (empty($publicKeySet)) {
// Failed to fetch public keyset from URL.
throw new LtiException(static::ERR_FETCH_PUBLIC_KEY);
}
// Find key used to sign the JWT (matches the KID in the header)
foreach ($publicKeySet['keys'] as $key) {
if ($key['kid'] == $this->jwt['header']['kid']) {
$key['alg'] = $this->getKeyAlgorithm($key);
try {
$keySet = JWK::parseKeySet([
'keys' => [$key],
]);
} catch (\Exception $e) {
// Do nothing
}
if (isset($keySet[$key['kid']])) {
return $keySet[$key['kid']];
}
}
}
// Could not find public key with a matching kid and alg.
throw new LtiException(static::ERR_NO_PUBLIC_KEY);
}
/**
* If alg is omitted from the JWK, infer it from the JWT header alg.
* See https://datatracker.ietf.org/doc/html/rfc7517#section-4.4.
*/
private function getKeyAlgorithm(array $key): string
{
if (isset($key['alg'])) {
return $key['alg'];
}
// The header alg must match the key type (family) specified in the JWK's kty.
if ($this->jwtAlgMatchesJwkKty($key)) {
return $this->jwt['header']['alg'];
}
throw new LtiException(static::ERR_MISMATCHED_ALG_KEY);
}
private function jwtAlgMatchesJwkKty($key): bool
{
$jwtAlg = $this->jwt['header']['alg'];
return isset(static::$ltiSupportedAlgs[$jwtAlg]) &&
static::$ltiSupportedAlgs[$jwtAlg] === $key['kty'];
}
private function cacheLaunchData()
{
$this->cache->cacheLaunchData($this->launch_id, $this->jwt['body']);
return $this;
}
private function validateState()
{
// Check State for OIDC.
if ($this->cookie->getCookie(LtiOidcLogin::COOKIE_PREFIX.$this->request['state']) !== $this->request['state']) {
// Error if state doesn't match
throw new LtiException(static::ERR_STATE_NOT_FOUND);
}
return $this;
}
private function validateJwtFormat()
{
$jwt = $this->request['id_token'] ?? null;
if (empty($jwt)) {
throw new LtiException(static::ERR_MISSING_ID_TOKEN);
}
// Get parts of JWT.
$jwt_parts = explode('.', $jwt);
if (count($jwt_parts) !== 3) {
// Invalid number of parts in JWT.
throw new LtiException(static::ERR_INVALID_ID_TOKEN);
}
// Decode JWT headers.
$this->jwt['header'] = json_decode(JWT::urlsafeB64Decode($jwt_parts[0]), true);
// Decode JWT Body.
$this->jwt['body'] = json_decode(JWT::urlsafeB64Decode($jwt_parts[1]), true);
return $this;
}
private function validateNonce()
{
if (!isset($this->jwt['body']['nonce'])) {
throw new LtiException(static::ERR_MISSING_NONCE);
}
if (!$this->cache->checkNonceIsValid($this->jwt['body']['nonce'], $this->request['state'])) {
throw new LtiException(static::ERR_INVALID_NONCE);
}
return $this;
}
private function validateRegistration()
{
// Find registration.
$client_id = is_array($this->jwt['body']['aud']) ? $this->jwt['body']['aud'][0] : $this->jwt['body']['aud'];
$this->registration = $this->db->findRegistrationByIssuer($this->jwt['body']['iss'], $client_id);
if (empty($this->registration)) {
throw new LtiException(static::ERR_MISSING_REGISTRATION);
}
// Check client id.
if ($client_id !== $this->registration->getClientId()) {
// Client not registered.
throw new LtiException(static::ERR_CLIENT_NOT_REGISTERED);
}
return $this;
}
private function validateJwtSignature()
{
if (!isset($this->jwt['header']['kid'])) {
throw new LtiException(static::ERR_NO_KID);
}
// Fetch public key.
$public_key = $this->getPublicKey();
// Validate JWT signature
try {
JWT::decode($this->request['id_token'], $public_key, ['RS256']);
} catch (ExpiredException $e) {
// Error validating signature.
throw new LtiException(static::ERR_INVALID_SIGNATURE);
}
return $this;
}
private function validateDeployment()
{
if (!isset($this->jwt['body'][LtiConstants::DEPLOYMENT_ID])) {
throw new LtiException(static::ERR_MISSING_DEPLOYEMENT_ID);
}
// Find deployment.
$client_id = is_array($this->jwt['body']['aud']) ? $this->jwt['body']['aud'][0] : $this->jwt['body']['aud'];
$deployment = $this->db->findDeployment($this->jwt['body']['iss'], $this->jwt['body'][LtiConstants::DEPLOYMENT_ID], $client_id);
if (empty($deployment)) {
// deployment not recognized.
throw new LtiException(static::ERR_NO_DEPLOYMENT);
}
return $this;
}
private function validateMessage()
{
if (empty($this->jwt['body'][LtiConstants::MESSAGE_TYPE])) {
// Unable to identify message type.
throw new LtiException(static::ERR_INVALID_MESSAGE_TYPE);
}
/**
* @todo Fix this nonsense
*/
// Create instances of all validators
$validators = [
new DeepLinkMessageValidator(),
new ResourceMessageValidator(),
new SubmissionReviewMessageValidator(),
];
$message_validator = false;
foreach ($validators as $validator) {
if ($validator->canValidate($this->jwt['body'])) {
if ($message_validator !== false) {
// Can't have more than one validator apply at a time.
throw new LtiException(static::ERR_VALIDATOR_CONFLICT);
}
$message_validator = $validator;
}
}
if ($message_validator === false) {
throw new LtiException(static::ERR_UNRECOGNIZED_MESSAGE_TYPE);
}
if (!$message_validator->validate($this->jwt['body'])) {
throw new LtiException(static::ERR_INVALID_MESSAGE);
}
return $this;
}
}