Search moodle.org's
Developer Documentation

See Release Notes
Long Term Support Release

  • Bug fixes for general core bugs in 4.1.x will end 13 November 2023 (12 months).
  • Bug fixes for security issues in 4.1.x will end 10 November 2025 (36 months).
  • PHP version: minimum PHP 7.4.0 Note: minimum PHP version has increased since Moodle 4.0. PHP 8.0.x is supported too.

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  }