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