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