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