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