Search moodle.org's
Developer Documentation

See Release Notes

  • Bug fixes for general core bugs in 4.0.x will end 8 May 2023 (12 months).
  • Bug fixes for security issues in 4.0.x will end 13 November 2023 (18 months).
  • PHP version: minimum PHP 7.3.0 Note: the minimum PHP version has increased since Moodle 3.10. PHP 7.4.x is also supported.

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  }