Proyectos de Subversion Moodle

Rev

| Ultima modificación | Ver Log |

Rev Autor Línea Nro. Línea
1 efrain 1
<?php
2
 
3
namespace Packback\Lti1p3;
4
 
5
use Exception;
6
use Firebase\JWT\ExpiredException;
7
use Firebase\JWT\JWK;
8
use Firebase\JWT\JWT;
9
use Firebase\JWT\Key;
10
use GuzzleHttp\Exception\TransferException;
11
use Packback\Lti1p3\Interfaces\ICache;
12
use Packback\Lti1p3\Interfaces\ICookie;
13
use Packback\Lti1p3\Interfaces\IDatabase;
14
use Packback\Lti1p3\Interfaces\ILtiDeployment;
15
use Packback\Lti1p3\Interfaces\ILtiRegistration;
16
use Packback\Lti1p3\Interfaces\ILtiServiceConnector;
17
use Packback\Lti1p3\Interfaces\IMigrationDatabase;
18
use Packback\Lti1p3\MessageValidators\DeepLinkMessageValidator;
19
use Packback\Lti1p3\MessageValidators\ResourceMessageValidator;
20
use Packback\Lti1p3\MessageValidators\SubmissionReviewMessageValidator;
21
 
22
class LtiMessageLaunch
23
{
24
    public const TYPE_DEEPLINK = 'LtiDeepLinkingRequest';
25
    public const TYPE_SUBMISSIONREVIEW = 'LtiSubmissionReviewRequest';
26
    public const TYPE_RESOURCELINK = 'LtiResourceLinkRequest';
27
    public const ERR_FETCH_PUBLIC_KEY = 'Failed to fetch public key.';
28
    public const ERR_NO_PUBLIC_KEY = 'Unable to find public key.';
29
    public const ERR_NO_MATCHING_PUBLIC_KEY = 'Unable to find a public key which matches your JWT.';
30
    public const ERR_STATE_NOT_FOUND = 'Please make sure you have cookies enabled in this browser and that you are not in private or incognito mode';
31
    public const ERR_MISSING_ID_TOKEN = 'Missing id_token.';
32
    public const ERR_INVALID_ID_TOKEN = 'Invalid id_token, JWT must contain 3 parts';
33
    public const ERR_MISSING_NONCE = 'Missing Nonce.';
34
    public const ERR_INVALID_NONCE = 'Invalid Nonce.';
35
 
36
    /**
37
     * :issuerUrl and :clientId are used to substitute the queried issuerUrl
38
     * and clientId. Do not change those substrings without changing how the
39
     * error message is built.
40
     */
41
    public const ERR_MISSING_REGISTRATION = 'LTI 1.3 Registration not found for Issuer :issuerUrl and Client ID :clientId. Please make sure the LMS has provided the right information, and that the LMS has been registered correctly in the tool.';
42
    public const ERR_CLIENT_NOT_REGISTERED = 'Client id not registered for this issuer.';
43
    public const ERR_NO_KID = 'No KID specified in the JWT Header.';
44
    public const ERR_INVALID_SIGNATURE = 'Invalid signature on id_token';
45
    public const ERR_MISSING_DEPLOYEMENT_ID = 'No deployment ID was specified';
46
    public const ERR_NO_DEPLOYMENT = 'Unable to find deployment.';
47
    public const ERR_INVALID_MESSAGE_TYPE = 'Invalid message type';
48
    public const ERR_UNRECOGNIZED_MESSAGE_TYPE = 'Unrecognized message type.';
49
    public const ERR_INVALID_MESSAGE = 'Message validation failed.';
50
    public const ERR_INVALID_ALG = 'Invalid alg was specified in the JWT header.';
51
    public const ERR_MISMATCHED_ALG_KEY = 'The alg specified in the JWT header is incompatible with the JWK key type.';
52
    public const ERR_OAUTH_KEY_SIGN_NOT_VERIFIED = 'Unable to upgrade from LTI 1.1 to 1.3. No OAuth Consumer Key matched this signature.';
53
    public const ERR_OAUTH_KEY_SIGN_MISSING = 'Unable to upgrade from LTI 1.1 to 1.3. The oauth_consumer_key_sign was not provided.';
54
    private array $request;
55
    private array $jwt;
56
    private ?ILtiRegistration $registration;
57
    private ?ILtiDeployment $deployment;
58
    public string $launch_id;
59
 
60
    // See https://www.imsglobal.org/spec/security/v1p1#approved-jwt-signing-algorithms.
61
    private static $ltiSupportedAlgs = [
62
        'RS256' => 'RSA',
63
        'RS384' => 'RSA',
64
        'RS512' => 'RSA',
65
        'ES256' => 'EC',
66
        'ES384' => 'EC',
67
        'ES512' => 'EC',
68
    ];
69
 
70
    public function __construct(
71
        private IDatabase $db,
72
        private ICache $cache,
73
        private ICookie $cookie,
74
        private ILtiServiceConnector $serviceConnector
75
    ) {
76
        $this->launch_id = uniqid('lti1p3_launch_', true);
77
    }
78
 
79
    /**
80
     * Static function to allow for method chaining without having to assign to a variable first.
81
     */
82
    public static function new(
83
        IDatabase $db,
84
        ICache $cache,
85
        ICookie $cookie,
86
        ILtiServiceConnector $serviceConnector
87
    ): self {
88
        return new LtiMessageLaunch($db, $cache, $cookie, $serviceConnector);
89
    }
90
 
91
    /**
92
     * Load an LtiMessageLaunch from a Cache using a launch id.
93
     *
94
     * @throws LtiException Will throw an LtiException if validation fails or launch cannot be found
95
     */
96
    public static function fromCache(
97
        string $launch_id,
98
        IDatabase $db,
99
        ICache $cache,
100
        ICookie $cookie,
101
        ILtiServiceConnector $serviceConnector
102
    ): self {
103
        $new = new LtiMessageLaunch($db, $cache, $cookie, $serviceConnector);
104
        $new->launch_id = $launch_id;
105
        $new->jwt = ['body' => $new->cache->getLaunchData($launch_id)];
106
 
107
        return $new->validateRegistration();
108
    }
109
 
110
    public function setRequest(array $request): self
111
    {
112
        $this->request = $request;
113
 
114
        return $this;
115
    }
116
 
117
    public function initialize(array $request): self
118
    {
119
        return $this->setRequest($request)
120
            ->validate()
121
            ->migrate()
122
            ->cacheLaunchData();
123
    }
124
 
125
    /**
126
     * Validates all aspects of an incoming LTI message launch and caches the launch if successful.
127
     *
128
     * @throws LtiException Will throw an LtiException if validation fails
129
     */
130
    public function validate(): self
131
    {
132
        return $this->validateState()
133
            ->validateJwtFormat()
134
            ->validateNonce()
135
            ->validateRegistration()
136
            ->validateJwtSignature()
137
            ->validateDeployment()
138
            ->validateMessage();
139
    }
140
 
141
    public function migrate(): self
142
    {
143
        if (!$this->shouldMigrate()) {
144
            return $this->ensureDeploymentExists();
145
        }
146
 
147
        if (!isset($this->jwt['body'][LtiConstants::LTI1P1]['oauth_consumer_key_sign'])) {
148
            throw new LtiException(static::ERR_OAUTH_KEY_SIGN_MISSING);
149
        }
150
 
151
        if (!$this->matchingLti1p1KeyExists()) {
152
            throw new LtiException(static::ERR_OAUTH_KEY_SIGN_NOT_VERIFIED);
153
        }
154
 
155
        $this->deployment = $this->db->migrateFromLti1p1($this);
156
 
157
        return $this->ensureDeploymentExists();
158
    }
159
 
160
    public function cacheLaunchData(): self
161
    {
162
        $this->cache->cacheLaunchData($this->launch_id, $this->jwt['body']);
163
 
164
        return $this;
165
    }
166
 
167
    /**
168
     * Returns whether or not the current launch can use the names and roles service.
169
     */
170
    public function hasNrps(): bool
171
    {
172
        return isset($this->jwt['body'][LtiConstants::NRPS_CLAIM_SERVICE]['context_memberships_url']);
173
    }
174
 
175
    /**
176
     * Fetches an instance of the names and roles service for the current launch.
177
     */
178
    public function getNrps(): LtiNamesRolesProvisioningService
179
    {
180
        return new LtiNamesRolesProvisioningService(
181
            $this->serviceConnector,
182
            $this->registration,
183
            $this->jwt['body'][LtiConstants::NRPS_CLAIM_SERVICE]
184
        );
185
    }
186
 
187
    /**
188
     * Returns whether or not the current launch can use the groups service.
189
     */
190
    public function hasGs(): bool
191
    {
192
        return isset($this->jwt['body'][LtiConstants::GS_CLAIM_SERVICE]['context_groups_url']);
193
    }
194
 
195
    /**
196
     * Fetches an instance of the groups service for the current launch.
197
     */
198
    public function getGs(): LtiCourseGroupsService
199
    {
200
        return new LtiCourseGroupsService(
201
            $this->serviceConnector,
202
            $this->registration,
203
            $this->jwt['body'][LtiConstants::GS_CLAIM_SERVICE]
204
        );
205
    }
206
 
207
    /**
208
     * Returns whether or not the current launch can use the assignments and grades service.
209
     */
210
    public function hasAgs(): bool
211
    {
212
        return isset($this->jwt['body'][LtiConstants::AGS_CLAIM_ENDPOINT]);
213
    }
214
 
215
    /**
216
     * Fetches an instance of the assignments and grades service for the current launch.
217
     */
218
    public function getAgs(): LtiAssignmentsGradesService
219
    {
220
        return new LtiAssignmentsGradesService(
221
            $this->serviceConnector,
222
            $this->registration,
223
            $this->jwt['body'][LtiConstants::AGS_CLAIM_ENDPOINT]
224
        );
225
    }
226
 
227
    /**
228
     * Returns whether or not the current launch is a deep linking launch.
229
     */
230
    public function isDeepLinkLaunch(): bool
231
    {
232
        return $this->jwt['body'][LtiConstants::MESSAGE_TYPE] === static::TYPE_DEEPLINK;
233
    }
234
 
235
    /**
236
     * Fetches a deep link that can be used to construct a deep linking response.
237
     */
238
    public function getDeepLink(): LtiDeepLink
239
    {
240
        return new LtiDeepLink(
241
            $this->registration,
242
            $this->jwt['body'][LtiConstants::DEPLOYMENT_ID],
243
            $this->jwt['body'][LtiConstants::DL_DEEP_LINK_SETTINGS]
244
        );
245
    }
246
 
247
    /**
248
     * Returns whether or not the current launch is a submission review launch.
249
     */
250
    public function isSubmissionReviewLaunch(): bool
251
    {
252
        return $this->jwt['body'][LtiConstants::MESSAGE_TYPE] === static::TYPE_SUBMISSIONREVIEW;
253
    }
254
 
255
    /**
256
     * Returns whether or not the current launch is a resource launch.
257
     */
258
    public function isResourceLaunch(): bool
259
    {
260
        return $this->jwt['body'][LtiConstants::MESSAGE_TYPE] === static::TYPE_RESOURCELINK;
261
    }
262
 
263
    /**
264
     * Fetches the decoded body of the JWT used in the current launch.
265
     */
266
    public function getLaunchData(): array
267
    {
268
        return $this->jwt['body'];
269
    }
270
 
271
    /**
272
     * Get the unique launch id for the current launch.
273
     */
274
    public function getLaunchId(): string
275
    {
276
        return $this->launch_id;
277
    }
278
 
279
    public static function getMissingRegistrationErrorMsg(string $issuerUrl, ?string $clientId = null): string
280
    {
281
        // Guard against client ID being null
282
        if (!isset($clientId)) {
283
            $clientId = '(N/A)';
284
        }
285
 
286
        $search = [':issuerUrl', ':clientId'];
287
        $replace = [$issuerUrl, $clientId];
288
 
289
        return str_replace($search, $replace, static::ERR_MISSING_REGISTRATION);
290
    }
291
 
292
    /**
293
     * @throws LtiException
294
     */
295
    private function getPublicKey(): Key
296
    {
297
        $request = new ServiceRequest(
298
            ServiceRequest::METHOD_GET,
299
            $this->registration->getKeySetUrl(),
300
            ServiceRequest::TYPE_GET_KEYSET
301
        );
302
 
303
        // Download key set
304
        try {
305
            $response = $this->serviceConnector->makeRequest($request);
306
        } catch (TransferException $e) {
307
            throw new LtiException(static::ERR_NO_PUBLIC_KEY);
308
        }
309
        $publicKeySet = $this->serviceConnector->getResponseBody($response);
310
 
311
        if (empty($publicKeySet)) {
312
            // Failed to fetch public keyset from URL.
313
            throw new LtiException(static::ERR_FETCH_PUBLIC_KEY);
314
        }
315
 
316
        // Find key used to sign the JWT (matches the KID in the header)
317
        foreach ($publicKeySet['keys'] as $key) {
318
            if ($key['kid'] == $this->jwt['header']['kid']) {
319
                $key['alg'] = $this->getKeyAlgorithm($key);
320
 
321
                try {
322
                    $keySet = JWK::parseKeySet([
323
                        'keys' => [$key],
324
                    ]);
325
                } catch (Exception $e) {
326
                    // Do nothing
327
                }
328
 
329
                if (isset($keySet[$key['kid']])) {
330
                    return $keySet[$key['kid']];
331
                }
332
            }
333
        }
334
 
335
        // Could not find public key with a matching kid and alg.
336
        throw new LtiException(static::ERR_NO_MATCHING_PUBLIC_KEY);
337
    }
338
 
339
    /**
340
     * If alg is omitted from the JWK, infer it from the JWT header alg.
341
     * See https://datatracker.ietf.org/doc/html/rfc7517#section-4.4.
342
     */
343
    private function getKeyAlgorithm(array $key): string
344
    {
345
        if (isset($key['alg'])) {
346
            return $key['alg'];
347
        }
348
 
349
        // The header alg must match the key type (family) specified in the JWK's kty.
350
        if ($this->jwtAlgMatchesJwkKty($key)) {
351
            return $this->jwt['header']['alg'];
352
        }
353
 
354
        throw new LtiException(static::ERR_MISMATCHED_ALG_KEY);
355
    }
356
 
357
    private function jwtAlgMatchesJwkKty(array $key): bool
358
    {
359
        $jwtAlg = $this->jwt['header']['alg'];
360
 
361
        return isset(self::$ltiSupportedAlgs[$jwtAlg]) &&
362
            self::$ltiSupportedAlgs[$jwtAlg] === $key['kty'];
363
    }
364
 
365
    protected function validateState(): self
366
    {
367
        // Check State for OIDC.
368
        if ($this->cookie->getCookie(LtiOidcLogin::COOKIE_PREFIX.$this->request['state']) !== $this->request['state']) {
369
            // Error if state doesn't match
370
            throw new LtiException(static::ERR_STATE_NOT_FOUND);
371
        }
372
 
373
        return $this;
374
    }
375
 
376
    protected function validateJwtFormat(): self
377
    {
378
        if (!isset($this->request['id_token'])) {
379
            throw new LtiException(static::ERR_MISSING_ID_TOKEN);
380
        }
381
 
382
        // Get parts of JWT.
383
        $jwt_parts = explode('.', $this->request['id_token']);
384
 
385
        if (count($jwt_parts) !== 3) {
386
            // Invalid number of parts in JWT.
387
            throw new LtiException(static::ERR_INVALID_ID_TOKEN);
388
        }
389
 
390
        // Decode JWT headers.
391
        $this->jwt['header'] = json_decode(JWT::urlsafeB64Decode($jwt_parts[0]), true);
392
        // Decode JWT Body.
393
        $this->jwt['body'] = json_decode(JWT::urlsafeB64Decode($jwt_parts[1]), true);
394
 
395
        return $this;
396
    }
397
 
398
    protected function validateNonce(): self
399
    {
400
        if (!isset($this->jwt['body']['nonce'])) {
401
            throw new LtiException(static::ERR_MISSING_NONCE);
402
        }
403
        if (!$this->cache->checkNonceIsValid($this->jwt['body']['nonce'], $this->request['state'])) {
404
            throw new LtiException(static::ERR_INVALID_NONCE);
405
        }
406
 
407
        return $this;
408
    }
409
 
410
    protected function validateRegistration(): self
411
    {
412
        // Find registration.
413
        $clientId = $this->getAud();
414
        $issuerUrl = $this->jwt['body']['iss'];
415
        $this->registration = $this->db->findRegistrationByIssuer($issuerUrl, $clientId);
416
 
417
        if (!isset($this->registration)) {
418
            throw new LtiException($this->getMissingRegistrationErrorMsg($issuerUrl, $clientId));
419
        }
420
 
421
        // Check client id.
422
        if ($clientId !== $this->registration->getClientId()) {
423
            // Client not registered.
424
            throw new LtiException(static::ERR_CLIENT_NOT_REGISTERED);
425
        }
426
 
427
        return $this;
428
    }
429
 
430
    protected function validateJwtSignature(): self
431
    {
432
        if (!isset($this->jwt['header']['kid'])) {
433
            throw new LtiException(static::ERR_NO_KID);
434
        }
435
 
436
        // Fetch public key.
437
        $public_key = $this->getPublicKey();
438
 
439
        // Validate JWT signature
440
        try {
441
            $headers = new \stdClass();
442
            JWT::decode($this->request['id_token'], $public_key, $headers);
443
        } catch (ExpiredException $e) {
444
            // Error validating signature.
445
            throw new LtiException(static::ERR_INVALID_SIGNATURE);
446
        }
447
 
448
        return $this;
449
    }
450
 
451
    protected function validateDeployment(): self
452
    {
453
        if (!isset($this->jwt['body'][LtiConstants::DEPLOYMENT_ID])) {
454
            throw new LtiException(static::ERR_MISSING_DEPLOYEMENT_ID);
455
        }
456
 
457
        // Find deployment.
458
        $client_id = $this->getAud();
459
        $this->deployment = $this->db->findDeployment($this->jwt['body']['iss'], $this->jwt['body'][LtiConstants::DEPLOYMENT_ID], $client_id);
460
 
461
        if (!$this->canMigrate()) {
462
            return $this->ensureDeploymentExists();
463
        }
464
 
465
        return $this;
466
    }
467
 
468
    protected function validateMessage(): self
469
    {
470
        if (!isset($this->jwt['body'][LtiConstants::MESSAGE_TYPE])) {
471
            // Unable to identify message type.
472
            throw new LtiException(static::ERR_INVALID_MESSAGE_TYPE);
473
        }
474
 
475
        $validator = $this->getMessageValidator($this->jwt['body']);
476
 
477
        if (!isset($validator)) {
478
            throw new LtiException(static::ERR_UNRECOGNIZED_MESSAGE_TYPE);
479
        }
480
 
481
        $validator::validate($this->jwt['body']);
482
 
483
        return $this;
484
    }
485
 
486
    private function getMessageValidator(array $jwtBody): ?string
487
    {
488
        $availableValidators = [
489
            DeepLinkMessageValidator::class,
490
            ResourceMessageValidator::class,
491
            SubmissionReviewMessageValidator::class,
492
        ];
493
 
494
        // Filter out validators that cannot validate the message
495
        $applicableValidators = array_filter($availableValidators, function ($validator) use ($jwtBody) {
496
            return $validator::canValidate($jwtBody);
497
        });
498
 
499
        // There should be 0-1 validators. This will either return the validator, or null if none apply.
500
        return array_shift($applicableValidators);
501
    }
502
 
503
    private function getAud(): string
504
    {
505
        if (is_array($this->jwt['body']['aud'])) {
506
            return $this->jwt['body']['aud'][0];
507
        } else {
508
            return $this->jwt['body']['aud'];
509
        }
510
    }
511
 
512
    /**
513
     * @throws LtiException
514
     */
515
    private function ensureDeploymentExists(): self
516
    {
517
        if (!isset($this->deployment)) {
518
            throw new LtiException(static::ERR_NO_DEPLOYMENT);
519
        }
520
 
521
        return $this;
522
    }
523
 
524
    public function canMigrate(): bool
525
    {
526
        return $this->db instanceof IMigrationDatabase;
527
    }
528
 
529
    private function shouldMigrate(): bool
530
    {
531
        return $this->canMigrate()
532
            && $this->db->shouldMigrate($this);
533
    }
534
 
535
    private function matchingLti1p1KeyExists(): bool
536
    {
537
        $keys = $this->db->findLti1p1Keys($this);
538
 
539
        foreach ($keys as $key) {
540
            if ($this->oauthConsumerKeySignMatches($key)) {
541
                return true;
542
            }
543
        }
544
 
545
        return false;
546
    }
547
 
548
    private function oauthConsumerKeySignMatches(Lti1p1Key $key): bool
549
    {
550
        return $this->jwt['body'][LtiConstants::LTI1P1]['oauth_consumer_key_sign'] === $this->getOauthSignature($key);
551
    }
552
 
553
    private function getOauthSignature(Lti1p1Key $key): string
554
    {
555
        return $key->sign(
556
            $this->jwt['body'][LtiConstants::DEPLOYMENT_ID],
557
            $this->jwt['body']['iss'],
558
            $this->getAud(),
559
            $this->jwt['body']['exp'],
560
            $this->jwt['body']['nonce']
561
        );
562
    }
563
}