• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

packbackbooks / lti-1-3-php-library / 16526089919

25 Jul 2025 03:48PM UTC coverage: 97.702%. First build
16526089919

push

github

web-flow
Merge pull request #165 from packbackbooks/mgmt-194-cleanup

Update linting rules

80 of 86 new or added lines in 6 files covered. (93.02%)

1148 of 1175 relevant lines covered (97.7%)

6.46 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

96.62
/src/LtiMessageLaunch.php
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 and cross-site tracking enabled in the privacy and security settings of your browser.';
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

55
    // See https://www.imsglobal.org/spec/security/v1p1#approved-jwt-signing-algorithms.
56
    private static $ltiSupportedAlgs = [
57
        'RS256' => 'RSA',
58
        'RS384' => 'RSA',
59
        'RS512' => 'RSA',
60
        'ES256' => 'EC',
61
        'ES384' => 'EC',
62
        'ES512' => 'EC',
63
    ];
64
    public string $launch_id;
65
    private array $request;
66
    private array $jwt;
67
    private ?ILtiRegistration $registration;
68
    private ?ILtiDeployment $deployment;
69

70
    public function __construct(
53✔
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);
53✔
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(
14✔
83
        IDatabase $db,
84
        ICache $cache,
85
        ICookie $cookie,
86
        ILtiServiceConnector $serviceConnector
87
    ): self {
88
        return new LtiMessageLaunch($db, $cache, $cookie, $serviceConnector);
14✔
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(
19✔
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);
19✔
104
        $new->launch_id = $launch_id;
19✔
105
        $new->jwt = ['body' => $new->cache->getLaunchData($launch_id)];
19✔
106

107
        return $new->validateRegistration();
19✔
108
    }
109

110
    public static function getMissingRegistrationErrorMsg(string $issuerUrl, ?string $clientId = null): string
1✔
111
    {
112
        // Guard against client ID being null
113
        if (!isset($clientId)) {
1✔
NEW
114
            $clientId = '(N/A)';
×
115
        }
116

117
        $search = [':issuerUrl', ':clientId'];
1✔
118
        $replace = [$issuerUrl, $clientId];
1✔
119

120
        return str_replace($search, $replace, static::ERR_MISSING_REGISTRATION);
1✔
121
    }
122

123
    public function setRequest(array $request): self
32✔
124
    {
125
        $this->request = $request;
32✔
126

127
        return $this;
32✔
128
    }
129

130
    public function initialize(array $request): self
17✔
131
    {
132
        return $this->setRequest($request)
17✔
133
            ->validate()
17✔
134
            ->migrate()
17✔
135
            ->cacheLaunchData();
17✔
136
    }
137

138
    /**
139
     * Validates all aspects of an incoming LTI message launch and caches the launch if successful.
140
     *
141
     * @throws LtiException Will throw an LtiException if validation fails
142
     */
143
    public function validate(): self
32✔
144
    {
145
        return $this->validateState()
32✔
146
            ->validateJwtFormat()
32✔
147
            ->validateNonce()
32✔
148
            ->validateRegistration()
32✔
149
            ->validateJwtSignature()
32✔
150
            ->validateDeployment()
32✔
151
            ->validateMessage();
32✔
152
    }
153

154
    public function migrate(): self
9✔
155
    {
156
        if (!$this->shouldMigrate()) {
9✔
157
            return $this->ensureDeploymentExists();
2✔
158
        }
159

160
        if (!isset($this->jwt['body'][LtiConstants::LTI1P1]['oauth_consumer_key_sign'])) {
7✔
161
            throw new LtiException(static::ERR_OAUTH_KEY_SIGN_MISSING);
2✔
162
        }
163

164
        if (!$this->matchingLti1p1KeyExists()) {
5✔
165
            throw new LtiException(static::ERR_OAUTH_KEY_SIGN_NOT_VERIFIED);
2✔
166
        }
167

168
        $this->deployment = $this->db->migrateFromLti1p1($this);
3✔
169

170
        return $this->ensureDeploymentExists();
3✔
171
    }
172

173
    public function cacheLaunchData(): self
4✔
174
    {
175
        $this->cache->cacheLaunchData($this->launch_id, $this->jwt['body']);
4✔
176

177
        return $this;
4✔
178
    }
179

180
    /**
181
     * Returns whether or not the current launch can use the names and roles service.
182
     */
183
    public function hasNrps(): bool
2✔
184
    {
185
        return isset($this->jwt['body'][LtiConstants::NRPS_CLAIM_SERVICE]['context_memberships_url']);
2✔
186
    }
187

188
    /**
189
     * Fetches an instance of the names and roles service for the current launch.
190
     */
191
    public function getNrps(): LtiNamesRolesProvisioningService
1✔
192
    {
193
        return new LtiNamesRolesProvisioningService(
1✔
194
            $this->serviceConnector,
1✔
195
            $this->registration,
1✔
196
            $this->jwt['body'][LtiConstants::NRPS_CLAIM_SERVICE]
1✔
197
        );
1✔
198
    }
199

200
    /**
201
     * Returns whether or not the current launch can use the groups service.
202
     */
203
    public function hasGs(): bool
2✔
204
    {
205
        return isset($this->jwt['body'][LtiConstants::GS_CLAIM_SERVICE]['context_groups_url']);
2✔
206
    }
207

208
    /**
209
     * Fetches an instance of the groups service for the current launch.
210
     */
211
    public function getGs(): LtiCourseGroupsService
1✔
212
    {
213
        return new LtiCourseGroupsService(
1✔
214
            $this->serviceConnector,
1✔
215
            $this->registration,
1✔
216
            $this->jwt['body'][LtiConstants::GS_CLAIM_SERVICE]
1✔
217
        );
1✔
218
    }
219

220
    /**
221
     * Returns whether or not the current launch can use the assignments and grades service.
222
     */
223
    public function hasAgs(): bool
2✔
224
    {
225
        return isset($this->jwt['body'][LtiConstants::AGS_CLAIM_ENDPOINT]);
2✔
226
    }
227

228
    /**
229
     * Fetches an instance of the assignments and grades service for the current launch.
230
     */
231
    public function getAgs(): LtiAssignmentsGradesService
1✔
232
    {
233
        return new LtiAssignmentsGradesService(
1✔
234
            $this->serviceConnector,
1✔
235
            $this->registration,
1✔
236
            $this->jwt['body'][LtiConstants::AGS_CLAIM_ENDPOINT]
1✔
237
        );
1✔
238
    }
239

240
    /**
241
     * Returns whether or not the current launch is a deep linking launch.
242
     */
243
    public function isDeepLinkLaunch(): bool
2✔
244
    {
245
        return $this->jwt['body'][LtiConstants::MESSAGE_TYPE] === static::TYPE_DEEPLINK;
2✔
246
    }
247

248
    /**
249
     * Fetches a deep link that can be used to construct a deep linking response.
250
     */
251
    public function getDeepLink(): LtiDeepLink
1✔
252
    {
253
        return new LtiDeepLink(
1✔
254
            $this->registration,
1✔
255
            $this->jwt['body'][LtiConstants::DEPLOYMENT_ID],
1✔
256
            $this->jwt['body'][LtiConstants::DL_DEEP_LINK_SETTINGS]
1✔
257
        );
1✔
258
    }
259

260
    /**
261
     * Returns whether or not the current launch is a submission review launch.
262
     */
263
    public function isSubmissionReviewLaunch(): bool
2✔
264
    {
265
        return $this->jwt['body'][LtiConstants::MESSAGE_TYPE] === static::TYPE_SUBMISSIONREVIEW;
2✔
266
    }
267

268
    /**
269
     * Returns whether or not the current launch is a resource launch.
270
     */
271
    public function isResourceLaunch(): bool
2✔
272
    {
273
        return $this->jwt['body'][LtiConstants::MESSAGE_TYPE] === static::TYPE_RESOURCELINK;
2✔
274
    }
275

276
    /**
277
     * Fetches the decoded body of the JWT used in the current launch.
278
     */
279
    public function getLaunchData(): array
1✔
280
    {
281
        return $this->jwt['body'];
1✔
282
    }
283

284
    /**
285
     * Get the unique launch id for the current launch.
286
     */
287
    public function getLaunchId(): string
1✔
288
    {
289
        return $this->launch_id;
1✔
290
    }
291

292
    public function canMigrate(): bool
17✔
293
    {
294
        return $this->db instanceof IMigrationDatabase;
17✔
295
    }
296

297
    protected function validateState(): self
32✔
298
    {
299
        // Check State for OIDC.
300
        if ($this->cookie->getCookie(LtiOidcLogin::COOKIE_PREFIX.$this->request['state']) !== $this->request['state']) {
32✔
301
            // Error if state doesn't match
302
            throw new LtiException(static::ERR_STATE_NOT_FOUND);
1✔
303
        }
304

305
        return $this;
31✔
306
    }
307

308
    protected function validateJwtFormat(): self
31✔
309
    {
310
        if (!isset($this->request['id_token'])) {
31✔
311
            throw new LtiException(static::ERR_MISSING_ID_TOKEN);
1✔
312
        }
313

314
        // Get parts of JWT.
315
        $jwt_parts = explode('.', $this->request['id_token']);
30✔
316

317
        if (count($jwt_parts) !== 3) {
30✔
318
            // Invalid number of parts in JWT.
319
            throw new LtiException(static::ERR_INVALID_ID_TOKEN);
2✔
320
        }
321

322
        // Decode JWT headers.
323
        $this->jwt['header'] = json_decode(JWT::urlsafeB64Decode($jwt_parts[0]), true);
28✔
324
        // Decode JWT Body.
325
        $this->jwt['body'] = json_decode(JWT::urlsafeB64Decode($jwt_parts[1]), true);
28✔
326

327
        return $this;
28✔
328
    }
329

330
    protected function validateNonce(): self
28✔
331
    {
332
        if (!isset($this->jwt['body']['nonce'])) {
28✔
333
            throw new LtiException(static::ERR_MISSING_NONCE);
2✔
334
        }
335
        if (!$this->cache->checkNonceIsValid($this->jwt['body']['nonce'], $this->request['state'])) {
27✔
336
            throw new LtiException(static::ERR_INVALID_NONCE);
1✔
337
        }
338

339
        return $this;
26✔
340
    }
341

342
    protected function validateRegistration(): self
45✔
343
    {
344
        // Find registration.
345
        $clientId = $this->getAud();
45✔
346
        $issuerUrl = $this->jwt['body']['iss'];
45✔
347
        $this->registration = $this->db->findRegistrationByIssuer($issuerUrl, $clientId);
45✔
348

349
        if (!isset($this->registration)) {
45✔
350
            throw new LtiException($this->getMissingRegistrationErrorMsg($issuerUrl, $clientId));
1✔
351
        }
352

353
        // Check client id.
354
        if ($clientId !== $this->registration->getClientId()) {
44✔
355
            // Client not registered.
356
            throw new LtiException(static::ERR_CLIENT_NOT_REGISTERED);
1✔
357
        }
358

359
        return $this;
43✔
360
    }
361

362
    protected function validateJwtSignature(): self
24✔
363
    {
364
        if (!isset($this->jwt['header']['kid'])) {
24✔
365
            throw new LtiException(static::ERR_NO_KID);
1✔
366
        }
367

368
        // Fetch public key.
369
        $public_key = $this->getPublicKey();
23✔
370

371
        // Validate JWT signature
372
        try {
373
            $headers = new \stdClass;
20✔
374
            JWT::decode($this->request['id_token'], $public_key, $headers);
20✔
375
        } catch (ExpiredException $e) {
2✔
376
            // Error validating signature.
377
            throw new LtiException(static::ERR_INVALID_SIGNATURE, previous: $e);
2✔
378
        }
379

380
        return $this;
19✔
381
    }
382

383
    protected function validateDeployment(): self
19✔
384
    {
385
        if (!isset($this->jwt['body'][LtiConstants::DEPLOYMENT_ID])) {
19✔
386
            throw new LtiException(static::ERR_MISSING_DEPLOYEMENT_ID);
3✔
387
        }
388

389
        // Find deployment.
390
        $client_id = $this->getAud();
17✔
391
        $this->deployment = $this->db->findDeployment($this->jwt['body']['iss'], $this->jwt['body'][LtiConstants::DEPLOYMENT_ID], $client_id);
17✔
392

393
        if (!$this->canMigrate()) {
17✔
394
            return $this->ensureDeploymentExists();
10✔
395
        }
396

397
        return $this;
7✔
398
    }
399

400
    protected function validateMessage(): self
16✔
401
    {
402
        if (!isset($this->jwt['body'][LtiConstants::MESSAGE_TYPE])) {
16✔
403
            // Unable to identify message type.
404
            throw new LtiException(static::ERR_INVALID_MESSAGE_TYPE);
3✔
405
        }
406

407
        $validator = $this->getMessageValidator($this->jwt['body']);
14✔
408

409
        if (!isset($validator)) {
14✔
410
            throw new LtiException(static::ERR_UNRECOGNIZED_MESSAGE_TYPE);
×
411
        }
412

413
        $validator::validate($this->jwt['body']);
14✔
414

415
        return $this;
10✔
416
    }
417

418
    /**
419
     * @throws LtiException
420
     */
421
    private function getPublicKey(): Key
23✔
422
    {
423
        $request = new ServiceRequest(
23✔
424
            ServiceRequest::METHOD_GET,
23✔
425
            $this->registration->getKeySetUrl(),
23✔
426
            ServiceRequest::TYPE_GET_KEYSET
23✔
427
        );
23✔
428

429
        // Download key set
430
        try {
431
            $response = $this->serviceConnector->makeRequest($request);
23✔
NEW
432
        } catch (TransferException $e) {
×
NEW
433
            throw new LtiException(static::ERR_NO_PUBLIC_KEY, previous: $e);
×
434
        }
435
        $publicKeySet = $this->serviceConnector->getResponseBody($response);
23✔
436

437
        if (empty($publicKeySet)) {
23✔
438
            // Failed to fetch public keyset from URL.
439
            throw new LtiException(static::ERR_FETCH_PUBLIC_KEY);
1✔
440
        }
441

442
        // Find key used to sign the JWT (matches the KID in the header)
443
        foreach ($publicKeySet['keys'] as $key) {
22✔
444
            if ($key['kid'] == $this->jwt['header']['kid']) {
22✔
445
                $key['alg'] = $this->getKeyAlgorithm($key);
21✔
446

447
                try {
448
                    $keySet = JWK::parseKeySet([
20✔
449
                        'keys' => [$key],
20✔
450
                    ]);
20✔
NEW
451
                } catch (Exception $e) {
×
452
                    // Do nothing
453
                }
454

455
                if (isset($keySet[$key['kid']])) {
20✔
456
                    return $keySet[$key['kid']];
20✔
457
                }
458
            }
459
        }
460

461
        // Could not find public key with a matching kid and alg.
462
        throw new LtiException(static::ERR_NO_MATCHING_PUBLIC_KEY);
1✔
463
    }
464

465
    /**
466
     * If alg is omitted from the JWK, infer it from the JWT header alg.
467
     * See https://datatracker.ietf.org/doc/html/rfc7517#section-4.4.
468
     */
469
    private function getKeyAlgorithm(array $key): string
21✔
470
    {
471
        if (isset($key['alg'])) {
21✔
472
            return $key['alg'];
20✔
473
        }
474

475
        // The header alg must match the key type (family) specified in the JWK's kty.
476
        if ($this->jwtAlgMatchesJwkKty($key)) {
1✔
NEW
477
            return $this->jwt['header']['alg'];
×
478
        }
479

480
        throw new LtiException(static::ERR_MISMATCHED_ALG_KEY);
1✔
481
    }
482

483
    private function jwtAlgMatchesJwkKty(array $key): bool
1✔
484
    {
485
        $jwtAlg = $this->jwt['header']['alg'];
1✔
486

487
        return isset(self::$ltiSupportedAlgs[$jwtAlg]) &&
1✔
488
            self::$ltiSupportedAlgs[$jwtAlg] === $key['kty'];
1✔
489
    }
490

491
    private function getMessageValidator(array $jwtBody): ?string
14✔
492
    {
493
        $availableValidators = [
14✔
494
            DeepLinkMessageValidator::class,
14✔
495
            ResourceMessageValidator::class,
14✔
496
            SubmissionReviewMessageValidator::class,
14✔
497
        ];
14✔
498

499
        // Filter out validators that cannot validate the message
500
        $applicableValidators = array_filter($availableValidators, function ($validator) use ($jwtBody) {
14✔
501
            return $validator::canValidate($jwtBody);
14✔
502
        });
14✔
503

504
        // There should be 0-1 validators. This will either return the validator, or null if none apply.
505
        return array_shift($applicableValidators);
14✔
506
    }
507

508
    private function getAud(): string
45✔
509
    {
510
        if (is_array($this->jwt['body']['aud'])) {
45✔
511
            return $this->jwt['body']['aud'][0];
×
512
        } else {
513
            return $this->jwt['body']['aud'];
45✔
514
        }
515
    }
516

517
    /**
518
     * @throws LtiException
519
     */
520
    private function ensureDeploymentExists(): self
13✔
521
    {
522
        if (!isset($this->deployment)) {
13✔
523
            throw new LtiException(static::ERR_NO_DEPLOYMENT);
2✔
524
        }
525

526
        return $this;
11✔
527
    }
528

529
    private function shouldMigrate(): bool
9✔
530
    {
531
        return $this->canMigrate()
9✔
532
            && $this->db->shouldMigrate($this);
9✔
533
    }
534

535
    private function matchingLti1p1KeyExists(): bool
5✔
536
    {
537
        $keys = $this->db->findLti1p1Keys($this);
5✔
538

539
        foreach ($keys as $key) {
5✔
540
            if ($this->oauthConsumerKeySignMatches($key)) {
5✔
541
                return true;
3✔
542
            }
543
        }
544

545
        return false;
2✔
546
    }
547

548
    private function oauthConsumerKeySignMatches(Lti1p1Key $key): bool
5✔
549
    {
550
        return $this->jwt['body'][LtiConstants::LTI1P1]['oauth_consumer_key_sign'] === $this->getOauthSignature($key);
5✔
551
    }
552

553
    private function getOauthSignature(Lti1p1Key $key): string
5✔
554
    {
555
        return $key->sign(
5✔
556
            $this->jwt['body'][LtiConstants::DEPLOYMENT_ID],
5✔
557
            $this->jwt['body']['iss'],
5✔
558
            $this->getAud(),
5✔
559
            $this->jwt['body']['exp'],
5✔
560
            $this->jwt['body']['nonce']
5✔
561
        );
5✔
562
    }
563
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc