Search moodle.org's
Developer Documentation

See Release Notes

  • Bug fixes for general core bugs in 4.2.x will end 22 April 2024 (12 months).
  • Bug fixes for security issues in 4.2.x will end 7 October 2024 (18 months).
  • PHP version: minimum PHP 8.0.0 Note: minimum PHP version has increased since Moodle 4.1. PHP 8.1.x is supported too.

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  }