Search moodle.org's
Developer Documentation

See Release Notes

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

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  }