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 310 and 401] [Versions 311 and 401]

   1  <?php
   2  // This file is part of Moodle - http://moodle.org/
   3  //
   4  // Moodle is free software: you can redistribute it and/or modify
   5  // it under the terms of the GNU General Public License as published by
   6  // the Free Software Foundation, either version 3 of the License, or
   7  // (at your option) any later version.
   8  //
   9  // Moodle is distributed in the hope that it will be useful,
  10  // but WITHOUT ANY WARRANTY; without even the implied warranty of
  11  // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
  12  // GNU General Public License for more details.
  13  //
  14  // You should have received a copy of the GNU General Public License
  15  // along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
  16  
  17  /**
  18   * A Helper for LTI Dynamic Registration.
  19   *
  20   * @package    mod_lti
  21   * @copyright  2020 Claude Vervoort (Cengage), Carlos Costa, Adrian Hutchinson (Macgraw Hill)
  22   * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  23   */
  24  namespace mod_lti\local\ltiopenid;
  25  
  26  defined('MOODLE_INTERNAL') || die;
  27  
  28  require_once($CFG->dirroot . '/mod/lti/locallib.php');
  29  use Firebase\JWT\JWK;
  30  use Firebase\JWT\JWT;
  31  use Firebase\JWT\Key;
  32  use stdClass;
  33  
  34  /**
  35   * This class exposes functions for LTI Dynamic Registration.
  36   *
  37   * @package    mod_lti
  38   * @copyright  2020 Claude Vervoort (Cengage), Carlos Costa, Adrian Hutchinson (Macgraw Hill)
  39   * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  40   */
  41  class registration_helper {
  42      /** score scope */
  43      const SCOPE_SCORE = 'https://purl.imsglobal.org/spec/lti-ags/scope/score';
  44      /** result scope */
  45      const SCOPE_RESULT = 'https://purl.imsglobal.org/spec/lti-ags/scope/result.readonly';
  46      /** lineitem read-only scope */
  47      const SCOPE_LINEITEM_RO = 'https://purl.imsglobal.org/spec/lti-ags/scope/lineitem.readonly';
  48      /** lineitem full access scope */
  49      const SCOPE_LINEITEM = 'https://purl.imsglobal.org/spec/lti-ags/scope/lineitem';
  50      /** Names and Roles (membership) scope */
  51      const SCOPE_NRPS = 'https://purl.imsglobal.org/spec/lti-nrps/scope/contextmembership.readonly';
  52      /** Tool Settings scope */
  53      const SCOPE_TOOL_SETTING = 'https://purl.imsglobal.org/spec/lti-ts/scope/toolsetting';
  54  
  55      /** Indicates the token is to create a new registration */
  56      const REG_TOKEN_OP_NEW_REG = 'reg';
  57      /** Indicates the token is to update an existing registration */
  58      const REG_TOKEN_OP_UPDATE_REG = 'reg-update';
  59  
  60      /**
  61       * Get an instance of this helper
  62       *
  63       * @return object
  64       */
  65      public static function get() {
  66          return new registration_helper();
  67      }
  68  
  69      /**
  70       * Function used to validate parameters.
  71       *
  72       * This function is needed because the payload contains nested
  73       * objects, and optional_param() does not support arrays of arrays.
  74       *
  75       * @param array $payload that may contain the parameter key
  76       * @param string $key the key of the value to be looked for in the payload
  77       * @param bool $required if required, not finding a value will raise a registration_exception
  78       *
  79       * @return mixed
  80       */
  81      private function get_parameter(array $payload, string $key, bool $required) {
  82          if (!isset($payload[$key]) || empty($payload[$key])) {
  83              if ($required) {
  84                  throw new registration_exception('missing required attribute '.$key, 400);
  85              }
  86              return null;
  87          }
  88          $parameter = $payload[$key];
  89          // Cleans parameters to avoid XSS and other issues.
  90          if (is_array($parameter)) {
  91              return clean_param_array($parameter, PARAM_TEXT, true);
  92          }
  93          return clean_param($parameter, PARAM_TEXT);
  94      }
  95  
  96      /**
  97       * Transforms an LTI 1.3 Registration to a Moodle LTI Config.
  98       *
  99       * @param array $registrationpayload the registration data received from the tool.
 100       * @param string $clientid the clientid to be issued for that tool.
 101       *
 102       * @return object the Moodle LTI config.
 103       */
 104      public function registration_to_config(array $registrationpayload, string $clientid): object {
 105          $responsetypes = $this->get_parameter($registrationpayload, 'response_types', true);
 106          $initiateloginuri = $this->get_parameter($registrationpayload, 'initiate_login_uri', true);
 107          $redirecturis = $this->get_parameter($registrationpayload, 'redirect_uris', true);
 108          $clientname = $this->get_parameter($registrationpayload, 'client_name', true);
 109          $jwksuri = $this->get_parameter($registrationpayload, 'jwks_uri', true);
 110          $tokenendpointauthmethod = $this->get_parameter($registrationpayload, 'token_endpoint_auth_method', true);
 111  
 112          $applicationtype = $this->get_parameter($registrationpayload, 'application_type', false);
 113          $logouri = $this->get_parameter($registrationpayload, 'logo_uri', false);
 114  
 115          $ltitoolconfiguration = $this->get_parameter($registrationpayload,
 116              'https://purl.imsglobal.org/spec/lti-tool-configuration', true);
 117  
 118          $domain = $this->get_parameter($ltitoolconfiguration, 'domain', false);
 119          $targetlinkuri = $this->get_parameter($ltitoolconfiguration, 'target_link_uri', false);
 120          $customparameters = $this->get_parameter($ltitoolconfiguration, 'custom_parameters', false);
 121          $scopes = explode(" ", $this->get_parameter($registrationpayload, 'scope', false) ?? '');
 122          $claims = $this->get_parameter($ltitoolconfiguration, 'claims', false);
 123          $messages = $ltitoolconfiguration['messages'] ?? [];
 124          $description = $this->get_parameter($ltitoolconfiguration, 'description', false);
 125  
 126          // Validate domain and target link.
 127          if (empty($domain)) {
 128              throw new registration_exception('missing_domain', 400);
 129          }
 130  
 131          $targetlinkuri = $targetlinkuri ?: 'https://'.$domain;
 132          // Stripping www as this is ignored for domain matching.
 133          $domain = lti_get_domain_from_url($domain);
 134          if ($domain !== lti_get_domain_from_url($targetlinkuri)) {
 135              throw new registration_exception('domain_targetlinkuri_mismatch', 400);
 136          }
 137  
 138          // Validate response type.
 139          // According to specification, for this scenario, id_token must be explicitly set.
 140          if (!in_array('id_token', $responsetypes)) {
 141              throw new registration_exception('invalid_response_types', 400);
 142          }
 143  
 144          // According to specification, this parameter needs to be an array.
 145          if (!is_array($redirecturis)) {
 146              throw new registration_exception('invalid_redirect_uris', 400);
 147          }
 148  
 149          // According to specification, for this scenario private_key_jwt must be explicitly set.
 150          if ($tokenendpointauthmethod !== 'private_key_jwt') {
 151              throw new registration_exception('invalid_token_endpoint_auth_method', 400);
 152          }
 153  
 154          if (!empty($applicationtype) && $applicationtype !== 'web') {
 155              throw new registration_exception('invalid_application_type', 400);
 156          }
 157  
 158          $config = new stdClass();
 159          $config->lti_clientid = $clientid;
 160          $config->lti_toolurl = $targetlinkuri;
 161          $config->lti_tooldomain = $domain;
 162          $config->lti_typename = $clientname;
 163          $config->lti_description = $description;
 164          $config->lti_ltiversion = LTI_VERSION_1P3;
 165          $config->lti_organizationid_default = LTI_DEFAULT_ORGID_SITEID;
 166          $config->lti_icon = $logouri;
 167          $config->lti_coursevisible = LTI_COURSEVISIBLE_PRECONFIGURED;
 168          $config->lti_contentitem = 0;
 169          // Sets Content Item.
 170          if (!empty($messages)) {
 171              $messagesresponse = [];
 172              foreach ($messages as $value) {
 173                  if ($value['type'] === 'LtiDeepLinkingRequest') {
 174                      $config->lti_contentitem = 1;
 175                      $config->lti_toolurl_ContentItemSelectionRequest = $value['target_link_uri'] ?? '';
 176                      array_push($messagesresponse, $value);
 177                  }
 178              }
 179          }
 180  
 181          $config->lti_keytype = 'JWK_KEYSET';
 182          $config->lti_publickeyset = $jwksuri;
 183          $config->lti_initiatelogin = $initiateloginuri;
 184          $config->lti_redirectionuris = implode(PHP_EOL, $redirecturis);
 185          $config->lti_customparameters = '';
 186          // Sets custom parameters.
 187          if (isset($customparameters)) {
 188              $paramssarray = [];
 189              foreach ($customparameters as $key => $value) {
 190                  array_push($paramssarray, $key . '=' . $value);
 191              }
 192              $config->lti_customparameters = implode(PHP_EOL, $paramssarray);
 193          }
 194          // Sets launch container.
 195          $config->lti_launchcontainer = LTI_LAUNCH_CONTAINER_EMBED_NO_BLOCKS;
 196  
 197          // Sets Service info based on scopes.
 198          $config->lti_acceptgrades = LTI_SETTING_NEVER;
 199          $config->ltiservice_gradesynchronization = 0;
 200          $config->ltiservice_memberships = 0;
 201          $config->ltiservice_toolsettings = 0;
 202          if (isset($scopes)) {
 203              // Sets Assignment and Grade Services info.
 204  
 205              if (in_array(self::SCOPE_SCORE, $scopes)) {
 206                  $config->lti_acceptgrades = LTI_SETTING_DELEGATE;
 207                  $config->ltiservice_gradesynchronization = 1;
 208              }
 209              if (in_array(self::SCOPE_RESULT, $scopes)) {
 210                  $config->lti_acceptgrades = LTI_SETTING_DELEGATE;
 211                  $config->ltiservice_gradesynchronization = 1;
 212              }
 213              if (in_array(self::SCOPE_LINEITEM_RO, $scopes)) {
 214                  $config->lti_acceptgrades = LTI_SETTING_DELEGATE;
 215                  $config->ltiservice_gradesynchronization = 1;
 216              }
 217              if (in_array(self::SCOPE_LINEITEM, $scopes)) {
 218                  $config->lti_acceptgrades = LTI_SETTING_DELEGATE;
 219                  $config->ltiservice_gradesynchronization = 2;
 220              }
 221  
 222              // Sets Names and Role Provisioning info.
 223              if (in_array(self::SCOPE_NRPS, $scopes)) {
 224                  $config->ltiservice_memberships = 1;
 225              }
 226  
 227              // Sets Tool Settings info.
 228              if (in_array(self::SCOPE_TOOL_SETTING, $scopes)) {
 229                  $config->ltiservice_toolsettings = 1;
 230              }
 231          }
 232  
 233          // Sets privacy settings.
 234          $config->lti_sendname = LTI_SETTING_NEVER;
 235          $config->lti_sendemailaddr = LTI_SETTING_NEVER;
 236          if (isset($claims)) {
 237              // Sets name privacy settings.
 238  
 239              if (in_array('name', $claims)) {
 240                  $config->lti_sendname = LTI_SETTING_ALWAYS;
 241              }
 242              if (in_array('given_name', $claims)) {
 243                  $config->lti_sendname = LTI_SETTING_ALWAYS;
 244              }
 245              if (in_array('family_name', $claims)) {
 246                  $config->lti_sendname = LTI_SETTING_ALWAYS;
 247              }
 248  
 249              // Sets email privacy settings.
 250              if (in_array('email', $claims)) {
 251                  $config->lti_sendemailaddr = LTI_SETTING_ALWAYS;
 252              }
 253          }
 254          return $config;
 255      }
 256  
 257      /**
 258       * Adds to the config the LTI 1.1 key and sign it with the 1.1 secret.
 259       *
 260       * @param array $lticonfig reference to lticonfig to which to add the 1.1 OAuth info.
 261       * @param string $key - LTI 1.1 OAuth Key
 262       * @param string $secret - LTI 1.1 OAuth Secret
 263       *
 264       */
 265      private function add_previous_key_claim(array &$lticonfig, string $key, string $secret) {
 266          if ($key) {
 267              $oauthconsumer = [];
 268              $oauthconsumer['key'] = $key;
 269              $oauthconsumer['nonce'] = random_string(random_int(10, 20));
 270              $oauthconsumer['sign'] = hash('sha256', $key.$secret.$oauthconsumer['nonce']);
 271              $lticonfig['oauth_consumer'] = $oauthconsumer;
 272          }
 273      }
 274  
 275      /**
 276       * Transforms a moodle LTI 1.3 Config to an OAuth/LTI Client Registration.
 277       *
 278       * @param object $config Moodle LTI Config.
 279       * @param int $typeid which is the LTI deployment id.
 280       * @param object $type tool instance in case the tool already exists.
 281       *
 282       * @return array the Client Registration as an associative array.
 283       */
 284      public function config_to_registration(object $config, int $typeid, object $type = null): array {
 285          $configarray = [];
 286          foreach ((array)$config as $k => $v) {
 287              if (substr($k, 0, 4) == 'lti_') {
 288                  $k = substr($k, 4);
 289              }
 290              $configarray[$k] = $v;
 291          }
 292          $config = (object) $configarray;
 293          $registrationresponse = [];
 294          $lticonfigurationresponse = [];
 295          $ltiversion = $type ? $type->ltiversion : $config->ltiversion;
 296          $lticonfigurationresponse['version'] = $ltiversion;
 297          if ($ltiversion === LTI_VERSION_1P3) {
 298              $registrationresponse['client_id'] = $type ? $type->clientid : $config->clientid;
 299              $registrationresponse['response_types'] = ['id_token'];
 300              $registrationresponse['jwks_uri'] = $config->publickeyset;
 301              $registrationresponse['initiate_login_uri'] = $config->initiatelogin;
 302              $registrationresponse['grant_types'] = ['client_credentials', 'implicit'];
 303              $registrationresponse['redirect_uris'] = explode(PHP_EOL, $config->redirectionuris);
 304              $registrationresponse['application_type'] = 'web';
 305              $registrationresponse['token_endpoint_auth_method'] = 'private_key_jwt';
 306          } else if ($ltiversion === LTI_VERSION_1 && $type) {
 307              $this->add_previous_key_claim($lticonfigurationresponse, $config->resourcekey, $config->password);
 308          } else if ($ltiversion === LTI_VERSION_2 && $type) {
 309              $toolproxy = $this->get_tool_proxy($type->toolproxyid);
 310              $this->add_previous_key_claim($lticonfigurationresponse, $toolproxy['guid'], $toolproxy['secret']);
 311          }
 312          $registrationresponse['client_name'] = $type ? $type->name : $config->typename;
 313          $registrationresponse['logo_uri'] = $type ? ($type->secureicon ?? $type->icon ?? '') : $config->icon ?? '';
 314          $lticonfigurationresponse['deployment_id'] = strval($typeid);
 315          $lticonfigurationresponse['target_link_uri'] = $type ? $type->baseurl : $config->toolurl ?? '';
 316          $lticonfigurationresponse['domain'] = $type ? $type->tooldomain : $config->tooldomain ?? '';
 317          $lticonfigurationresponse['description'] = $type ? $type->description ?? '' : $config->description ?? '';
 318          if ($config->contentitem ?? 0 == 1) {
 319              $contentitemmessage = [];
 320              $contentitemmessage['type'] = 'LtiDeepLinkingRequest';
 321              if (isset($config->toolurl_ContentItemSelectionRequest)) {
 322                  $contentitemmessage['target_link_uri'] = $config->toolurl_ContentItemSelectionRequest;
 323              }
 324              $lticonfigurationresponse['messages'] = [$contentitemmessage];
 325          }
 326          if (isset($config->customparameters) && !empty($config->customparameters)) {
 327              $params = [];
 328              foreach (explode(PHP_EOL, $config->customparameters) as $param) {
 329                  $split = explode('=', $param);
 330                  $params[$split[0]] = $split[1];
 331              }
 332              $lticonfigurationresponse['custom_parameters'] = $params;
 333          }
 334          $scopesresponse = [];
 335          if ($config->ltiservice_gradesynchronization ?? 0 > 0) {
 336              $scopesresponse[] = self::SCOPE_SCORE;
 337              $scopesresponse[] = self::SCOPE_RESULT;
 338              $scopesresponse[] = self::SCOPE_LINEITEM_RO;
 339          }
 340          if ($config->ltiservice_gradesynchronization ?? 0 == 2) {
 341              $scopesresponse[] = self::SCOPE_LINEITEM;
 342          }
 343          if ($config->ltiservice_memberships ?? 0 == 1) {
 344              $scopesresponse[] = self::SCOPE_NRPS;
 345          }
 346          if ($config->ltiservice_toolsettings ?? 0 == 1) {
 347              $scopesresponse[] = self::SCOPE_TOOL_SETTING;
 348          }
 349          $registrationresponse['scope'] = implode(' ', $scopesresponse);
 350  
 351          $claimsresponse = ['sub', 'iss'];
 352          if ($config->sendname ?? '' == LTI_SETTING_ALWAYS) {
 353              $claimsresponse[] = 'name';
 354              $claimsresponse[] = 'family_name';
 355              $claimsresponse[] = 'given_name';
 356          }
 357          if ($config->sendemailaddr ?? '' == LTI_SETTING_ALWAYS) {
 358              $claimsresponse[] = 'email';
 359          }
 360          $lticonfigurationresponse['claims'] = $claimsresponse;
 361          $registrationresponse['https://purl.imsglobal.org/spec/lti-tool-configuration'] = $lticonfigurationresponse;
 362          return $registrationresponse;
 363      }
 364  
 365      /**
 366       * Validates the registration token is properly signed and not used yet.
 367       * Return the client id to use for this registration.
 368       *
 369       * @param string $registrationtokenjwt registration token
 370       *
 371       * @return array with 2 keys: clientid for the registration, type but only if it's an update
 372       */
 373      public function validate_registration_token(string $registrationtokenjwt): array {
 374          global $DB;
 375          // JWK::parseKeySet uses RS256 algorithm by default.
 376          $keys = JWK::parseKeySet(jwks_helper::get_jwks());
 377          $registrationtoken = JWT::decode($registrationtokenjwt, $keys);
 378          $response = [];
 379          // Get clientid from registrationtoken.
 380          $clientid = $registrationtoken->sub;
 381          if ($registrationtoken->scope == self::REG_TOKEN_OP_NEW_REG) {
 382              // Checks if clientid is already registered.
 383              if (!empty($DB->get_record('lti_types', array('clientid' => $clientid)))) {
 384                  throw new registration_exception("token_already_used", 401);
 385              }
 386              $response['clientid'] = $clientid;
 387          } else if ($registrationtoken->scope == self::REG_TOKEN_OP_UPDATE_REG) {
 388              $tool = lti_get_type($registrationtoken->sub);
 389              if (!$tool) {
 390                  throw new registration_exception("Unknown client", 400);
 391              }
 392              $response['clientid'] = $tool->clientid ?? $this->new_clientid();
 393              $response['type'] = $tool;
 394          } else {
 395              throw new registration_exception("Incorrect scope", 403);
 396          }
 397          return $response;
 398      }
 399  
 400      /**
 401       * Initializes an array with the scopes for services supported by the LTI module
 402       *
 403       * @return array List of scopes
 404       */
 405      public function lti_get_service_scopes() {
 406  
 407          $services = lti_get_services();
 408          $scopes = array();
 409          foreach ($services as $service) {
 410              $servicescopes = $service->get_scopes();
 411              if (!empty($servicescopes)) {
 412                  $scopes = array_merge($scopes, $servicescopes);
 413              }
 414          }
 415          return $scopes;
 416      }
 417  
 418      /**
 419       * Generates a new client id string.
 420       *
 421       * @return string generated client id
 422       */
 423      public function new_clientid(): string {
 424          return random_string(15);
 425      }
 426  
 427      /**
 428       * Base64 encoded signature for LTI 1.1 migration.
 429       * @param string $key LTI 1.1 key
 430       * @param string $salt Salt value
 431       * @param string $secret LTI 1.1 secret
 432       *
 433       * @return string base64encoded hash
 434       */
 435      public function sign(string $key, string $salt, string $secret): string {
 436          return base64_encode(hash_hmac('sha-256', $key.$salt, $secret, true));
 437      }
 438  
 439      /**
 440       * Returns a tool proxy
 441       *
 442       * @param int $proxyid
 443       *
 444       * @return mixed Tool Proxy details
 445       */
 446      public function get_tool_proxy(int $proxyid) : array {
 447          return lti_get_tool_proxy($proxyid);
 448      }
 449  }