See Release Notes
Long Term Support Release
<?php // This file is part of Moodle - http://moodle.org/ // // Moodle is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // Moodle is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with Moodle. If not, see <http://www.gnu.org/licenses/>. /** * A Helper for LTI Dynamic Registration. * * @package mod_lti * @copyright 2020 Claude Vervoort (Cengage), Carlos Costa, Adrian Hutchinson (Macgraw Hill) * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace mod_lti\local\ltiopenid; defined('MOODLE_INTERNAL') || die; require_once($CFG->dirroot . '/mod/lti/locallib.php'); use Firebase\JWT\JWK; use Firebase\JWT\JWT;> use Firebase\JWT\Key;use stdClass; /** * This class exposes functions for LTI Dynamic Registration. * * @package mod_lti * @copyright 2020 Claude Vervoort (Cengage), Carlos Costa, Adrian Hutchinson (Macgraw Hill) * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class registration_helper { /** score scope */ const SCOPE_SCORE = 'https://purl.imsglobal.org/spec/lti-ags/scope/score'; /** result scope */ const SCOPE_RESULT = 'https://purl.imsglobal.org/spec/lti-ags/scope/result.readonly'; /** lineitem read-only scope */ const SCOPE_LINEITEM_RO = 'https://purl.imsglobal.org/spec/lti-ags/scope/lineitem.readonly'; /** lineitem full access scope */ const SCOPE_LINEITEM = 'https://purl.imsglobal.org/spec/lti-ags/scope/lineitem'; /** Names and Roles (membership) scope */ const SCOPE_NRPS = 'https://purl.imsglobal.org/spec/lti-nrps/scope/contextmembership.readonly'; /** Tool Settings scope */ const SCOPE_TOOL_SETTING = 'https://purl.imsglobal.org/spec/lti-ts/scope/toolsetting';> /** Indicates the token is to create a new registration */ > const REG_TOKEN_OP_NEW_REG = 'reg'; /** > /** Indicates the token is to update an existing registration */ * Function used to validate parameters. > const REG_TOKEN_OP_UPDATE_REG = 'reg-update'; * > * This function is needed because the payload contains nested > /** * objects, and optional_param() does not support arrays of arrays. > * Get an instance of this helper * > * * @param array $payload that may contain the parameter key > * @return object * @param string $key the key of the value to be looked for in the payload > */ * @param bool $required if required, not finding a value will raise a registration_exception > public static function get() { * > return new registration_helper(); * @return mixed > }*/< private static function get_parameter(array $payload, string $key, bool $required) {> private function get_parameter(array $payload, string $key, bool $required) {if (!isset($payload[$key]) || empty($payload[$key])) { if ($required) { throw new registration_exception('missing required attribute '.$key, 400); } return null; } $parameter = $payload[$key]; // Cleans parameters to avoid XSS and other issues. if (is_array($parameter)) { return clean_param_array($parameter, PARAM_TEXT, true); } return clean_param($parameter, PARAM_TEXT); } /** * Transforms an LTI 1.3 Registration to a Moodle LTI Config. * * @param array $registrationpayload the registration data received from the tool. * @param string $clientid the clientid to be issued for that tool. * * @return object the Moodle LTI config. */< public static function registration_to_config(array $registrationpayload, string $clientid): object { < $responsetypes = self::get_parameter($registrationpayload, 'response_types', true); < $initiateloginuri = self::get_parameter($registrationpayload, 'initiate_login_uri', true); < $redirecturis = self::get_parameter($registrationpayload, 'redirect_uris', true); < $clientname = self::get_parameter($registrationpayload, 'client_name', true); < $jwksuri = self::get_parameter($registrationpayload, 'jwks_uri', true); < $tokenendpointauthmethod = self::get_parameter($registrationpayload, 'token_endpoint_auth_method', true);> public function registration_to_config(array $registrationpayload, string $clientid): object { > $responsetypes = $this->get_parameter($registrationpayload, 'response_types', true); > $initiateloginuri = $this->get_parameter($registrationpayload, 'initiate_login_uri', true); > $redirecturis = $this->get_parameter($registrationpayload, 'redirect_uris', true); > $clientname = $this->get_parameter($registrationpayload, 'client_name', true); > $jwksuri = $this->get_parameter($registrationpayload, 'jwks_uri', true); > $tokenendpointauthmethod = $this->get_parameter($registrationpayload, 'token_endpoint_auth_method', true);< $applicationtype = self::get_parameter($registrationpayload, 'application_type', false); < $logouri = self::get_parameter($registrationpayload, 'logo_uri', false);> $applicationtype = $this->get_parameter($registrationpayload, 'application_type', false); > $logouri = $this->get_parameter($registrationpayload, 'logo_uri', false);< $ltitoolconfiguration = self::get_parameter($registrationpayload,> $ltitoolconfiguration = $this->get_parameter($registrationpayload,'https://purl.imsglobal.org/spec/lti-tool-configuration', true);< $domain = self::get_parameter($ltitoolconfiguration, 'domain', false); < $targetlinkuri = self::get_parameter($ltitoolconfiguration, 'target_link_uri', false); < $customparameters = self::get_parameter($ltitoolconfiguration, 'custom_parameters', false); < $scopes = explode(" ", self::get_parameter($registrationpayload, 'scope', false) ?? ''); < $claims = self::get_parameter($ltitoolconfiguration, 'claims', false);> $domain = $this->get_parameter($ltitoolconfiguration, 'domain', false); > $targetlinkuri = $this->get_parameter($ltitoolconfiguration, 'target_link_uri', false); > $customparameters = $this->get_parameter($ltitoolconfiguration, 'custom_parameters', false); > $scopes = explode(" ", $this->get_parameter($registrationpayload, 'scope', false) ?? ''); > $claims = $this->get_parameter($ltitoolconfiguration, 'claims', false);$messages = $ltitoolconfiguration['messages'] ?? [];< $description = self::get_parameter($ltitoolconfiguration, 'description', false);> $description = $this->get_parameter($ltitoolconfiguration, 'description', false);// Validate domain and target link. if (empty($domain)) { throw new registration_exception('missing_domain', 400); }>$targetlinkuri = $targetlinkuri ?: 'https://'.$domain;> // Stripping www as this is ignored for domain matching. if ($domain !== lti_get_domain_from_url($targetlinkuri)) { > $domain = lti_get_domain_from_url($domain);throw new registration_exception('domain_targetlinkuri_mismatch', 400); } // Validate response type. // According to specification, for this scenario, id_token must be explicitly set. if (!in_array('id_token', $responsetypes)) { throw new registration_exception('invalid_response_types', 400); } // According to specification, this parameter needs to be an array. if (!is_array($redirecturis)) { throw new registration_exception('invalid_redirect_uris', 400); } // According to specification, for this scenario private_key_jwt must be explicitly set. if ($tokenendpointauthmethod !== 'private_key_jwt') { throw new registration_exception('invalid_token_endpoint_auth_method', 400); } if (!empty($applicationtype) && $applicationtype !== 'web') { throw new registration_exception('invalid_application_type', 400); } $config = new stdClass(); $config->lti_clientid = $clientid; $config->lti_toolurl = $targetlinkuri; $config->lti_tooldomain = $domain; $config->lti_typename = $clientname; $config->lti_description = $description; $config->lti_ltiversion = LTI_VERSION_1P3; $config->lti_organizationid_default = LTI_DEFAULT_ORGID_SITEID; $config->lti_icon = $logouri; $config->lti_coursevisible = LTI_COURSEVISIBLE_PRECONFIGURED; $config->lti_contentitem = 0; // Sets Content Item. if (!empty($messages)) { $messagesresponse = []; foreach ($messages as $value) { if ($value['type'] === 'LtiDeepLinkingRequest') { $config->lti_contentitem = 1; $config->lti_toolurl_ContentItemSelectionRequest = $value['target_link_uri'] ?? ''; array_push($messagesresponse, $value); } } } $config->lti_keytype = 'JWK_KEYSET'; $config->lti_publickeyset = $jwksuri; $config->lti_initiatelogin = $initiateloginuri; $config->lti_redirectionuris = implode(PHP_EOL, $redirecturis); $config->lti_customparameters = ''; // Sets custom parameters. if (isset($customparameters)) { $paramssarray = []; foreach ($customparameters as $key => $value) { array_push($paramssarray, $key . '=' . $value); } $config->lti_customparameters = implode(PHP_EOL, $paramssarray); } // Sets launch container. $config->lti_launchcontainer = LTI_LAUNCH_CONTAINER_EMBED_NO_BLOCKS; // Sets Service info based on scopes. $config->lti_acceptgrades = LTI_SETTING_NEVER; $config->ltiservice_gradesynchronization = 0; $config->ltiservice_memberships = 0; $config->ltiservice_toolsettings = 0; if (isset($scopes)) { // Sets Assignment and Grade Services info. if (in_array(self::SCOPE_SCORE, $scopes)) { $config->lti_acceptgrades = LTI_SETTING_DELEGATE; $config->ltiservice_gradesynchronization = 1; } if (in_array(self::SCOPE_RESULT, $scopes)) { $config->lti_acceptgrades = LTI_SETTING_DELEGATE; $config->ltiservice_gradesynchronization = 1; } if (in_array(self::SCOPE_LINEITEM_RO, $scopes)) { $config->lti_acceptgrades = LTI_SETTING_DELEGATE; $config->ltiservice_gradesynchronization = 1; } if (in_array(self::SCOPE_LINEITEM, $scopes)) { $config->lti_acceptgrades = LTI_SETTING_DELEGATE; $config->ltiservice_gradesynchronization = 2; } // Sets Names and Role Provisioning info. if (in_array(self::SCOPE_NRPS, $scopes)) { $config->ltiservice_memberships = 1; } // Sets Tool Settings info. if (in_array(self::SCOPE_TOOL_SETTING, $scopes)) { $config->ltiservice_toolsettings = 1; } } // Sets privacy settings. $config->lti_sendname = LTI_SETTING_NEVER; $config->lti_sendemailaddr = LTI_SETTING_NEVER; if (isset($claims)) { // Sets name privacy settings. if (in_array('name', $claims)) { $config->lti_sendname = LTI_SETTING_ALWAYS; } if (in_array('given_name', $claims)) { $config->lti_sendname = LTI_SETTING_ALWAYS; } if (in_array('family_name', $claims)) { $config->lti_sendname = LTI_SETTING_ALWAYS; } // Sets email privacy settings. if (in_array('email', $claims)) { $config->lti_sendemailaddr = LTI_SETTING_ALWAYS; } } return $config; } /**> * Adds to the config the LTI 1.1 key and sign it with the 1.1 secret. * Transforms a moodle LTI 1.3 Config to an OAuth/LTI Client Registration. > * * > * @param array $lticonfig reference to lticonfig to which to add the 1.1 OAuth info. * @param object $config Moodle LTI Config. > * @param string $key - LTI 1.1 OAuth Key * @param int $typeid which is the LTI deployment id. > * @param string $secret - LTI 1.1 OAuth Secret * > * * @return array the Client Registration as an associative array. > */ */ > private function add_previous_key_claim(array &$lticonfig, string $key, string $secret) { public static function config_to_registration(object $config, int $typeid): array { > if ($key) { $registrationresponse = []; > $oauthconsumer = []; $registrationresponse['client_id'] = $config->lti_clientid; > $oauthconsumer['key'] = $key; $registrationresponse['token_endpoint_auth_method'] = ['private_key_jwt']; > $oauthconsumer['nonce'] = random_string(random_int(10, 20)); $registrationresponse['response_types'] = ['id_token']; > $oauthconsumer['sign'] = hash('sha256', $key.$secret.$oauthconsumer['nonce']); $registrationresponse['jwks_uri'] = $config->lti_publickeyset; > $lticonfig['oauth_consumer'] = $oauthconsumer; $registrationresponse['initiate_login_uri'] = $config->lti_initiatelogin; > } $registrationresponse['grant_types'] = ['client_credentials', 'implicit']; > } $registrationresponse['redirect_uris'] = explode(PHP_EOL, $config->lti_redirectionuris); > $registrationresponse['application_type'] = 'web'; > /**$registrationresponse['token_endpoint_auth_method'] = 'private_key_jwt';> * @param object $type tool instance in case the tool already exists.< public static function config_to_registration(object $config, int $typeid): array {> public function config_to_registration(object $config, int $typeid, object $type = null): array { > $configarray = []; > foreach ((array)$config as $k => $v) { > if (substr($k, 0, 4) == 'lti_') { > $k = substr($k, 4); > } > $configarray[$k] = $v; > } > $config = (object) $configarray;< $registrationresponse['client_id'] = $config->lti_clientid; < $registrationresponse['token_endpoint_auth_method'] = ['private_key_jwt'];> $lticonfigurationresponse = []; > $ltiversion = $type ? $type->ltiversion : $config->ltiversion; > $lticonfigurationresponse['version'] = $ltiversion; > if ($ltiversion === LTI_VERSION_1P3) { > $registrationresponse['client_id'] = $type ? $type->clientid : $config->clientid;< $registrationresponse['jwks_uri'] = $config->lti_publickeyset; < $registrationresponse['initiate_login_uri'] = $config->lti_initiatelogin;> $registrationresponse['jwks_uri'] = $config->publickeyset; > $registrationresponse['initiate_login_uri'] = $config->initiatelogin;< $registrationresponse['redirect_uris'] = explode(PHP_EOL, $config->lti_redirectionuris);> $registrationresponse['redirect_uris'] = explode(PHP_EOL, $config->redirectionuris);< $registrationresponse['client_name'] = $config->lti_typename; < $registrationresponse['logo_uri'] = $config->lti_icon ?? ''; < $lticonfigurationresponse = [];> } else if ($ltiversion === LTI_VERSION_1 && $type) { > $this->add_previous_key_claim($lticonfigurationresponse, $config->resourcekey, $config->password); > } else if ($ltiversion === LTI_VERSION_2 && $type) { > $toolproxy = $this->get_tool_proxy($type->toolproxyid); > $this->add_previous_key_claim($lticonfigurationresponse, $toolproxy['guid'], $toolproxy['secret']); > } > $registrationresponse['client_name'] = $type ? $type->name : $config->typename; > $registrationresponse['logo_uri'] = $type ? ($type->secureicon ?? $type->icon ?? '') : $config->icon ?? '';< $lticonfigurationresponse['target_link_uri'] = $config->lti_toolurl; < $lticonfigurationresponse['domain'] = $config->lti_tooldomain ?? ''; < $lticonfigurationresponse['description'] = $config->lti_description ?? ''; < if ($config->lti_contentitem == 1) {> $lticonfigurationresponse['target_link_uri'] = $type ? $type->baseurl : $config->toolurl ?? ''; > $lticonfigurationresponse['domain'] = $type ? $type->tooldomain : $config->tooldomain ?? ''; > $lticonfigurationresponse['description'] = $type ? $type->description ?? '' : $config->description ?? ''; > if ($config->contentitem ?? 0 == 1) {< if (isset($config->lti_toolurl_ContentItemSelectionRequest)) { < $contentitemmessage['target_link_uri'] = $config->lti_toolurl_ContentItemSelectionRequest;> if (isset($config->toolurl_ContentItemSelectionRequest)) { > $contentitemmessage['target_link_uri'] = $config->toolurl_ContentItemSelectionRequest;< if (isset($config->lti_customparameters) && !empty($config->lti_customparameters)) {> if (isset($config->customparameters) && !empty($config->customparameters)) {$params = [];< foreach (explode(PHP_EOL, $config->lti_customparameters) as $param) {> foreach (explode(PHP_EOL, $config->customparameters) as $param) {$split = explode('=', $param); $params[$split[0]] = $split[1]; } $lticonfigurationresponse['custom_parameters'] = $params; } $scopesresponse = [];< if ($config->ltiservice_gradesynchronization > 0) {> if ($config->ltiservice_gradesynchronization ?? 0 > 0) {$scopesresponse[] = self::SCOPE_SCORE; $scopesresponse[] = self::SCOPE_RESULT; $scopesresponse[] = self::SCOPE_LINEITEM_RO; }< if ($config->ltiservice_gradesynchronization == 2) {> if ($config->ltiservice_gradesynchronization ?? 0 == 2) {$scopesresponse[] = self::SCOPE_LINEITEM; }< if ($config->ltiservice_memberships == 1) {> if ($config->ltiservice_memberships ?? 0 == 1) {$scopesresponse[] = self::SCOPE_NRPS; }< if ($config->ltiservice_toolsettings == 1) {> if ($config->ltiservice_toolsettings ?? 0 == 1) {$scopesresponse[] = self::SCOPE_TOOL_SETTING; } $registrationresponse['scope'] = implode(' ', $scopesresponse); $claimsresponse = ['sub', 'iss'];< if ($config->lti_sendname == LTI_SETTING_ALWAYS) {> if ($config->sendname ?? '' == LTI_SETTING_ALWAYS) {$claimsresponse[] = 'name'; $claimsresponse[] = 'family_name'; $claimsresponse[] = 'given_name'; }< if ($config->lti_sendemailaddr == LTI_SETTING_ALWAYS) {> if ($config->sendemailaddr ?? '' == LTI_SETTING_ALWAYS) {$claimsresponse[] = 'email'; } $lticonfigurationresponse['claims'] = $claimsresponse; $registrationresponse['https://purl.imsglobal.org/spec/lti-tool-configuration'] = $lticonfigurationresponse; return $registrationresponse; } /** * Validates the registration token is properly signed and not used yet. * Return the client id to use for this registration. * * @param string $registrationtokenjwt registration token *< * @return string client id for the registration> * @return array with 2 keys: clientid for the registration, type but only if it's an update*/< public static function validate_registration_token(string $registrationtokenjwt): string {> public function validate_registration_token(string $registrationtokenjwt): array {global $DB;> // JWK::parseKeySet uses RS256 algorithm by default.$keys = JWK::parseKeySet(jwks_helper::get_jwks());< $registrationtoken = JWT::decode($registrationtokenjwt, $keys, ['RS256']); <> $registrationtoken = JWT::decode($registrationtokenjwt, $keys); > $response = [];// Get clientid from registrationtoken. $clientid = $registrationtoken->sub;<> if ($registrationtoken->scope == self::REG_TOKEN_OP_NEW_REG) {// Checks if clientid is already registered. if (!empty($DB->get_record('lti_types', array('clientid' => $clientid)))) { throw new registration_exception("token_already_used", 401); }< return $clientid;> $response['clientid'] = $clientid; > } else if ($registrationtoken->scope == self::REG_TOKEN_OP_UPDATE_REG) { > $tool = lti_get_type($registrationtoken->sub); > if (!$tool) { > throw new registration_exception("Unknown client", 400); > } > $response['clientid'] = $tool->clientid ?? $this->new_clientid(); > $response['type'] = $tool; > } else { > throw new registration_exception("Incorrect scope", 403); > } > return $response;} /** * Initializes an array with the scopes for services supported by the LTI module * * @return array List of scopes */< public static function lti_get_service_scopes() {> public function lti_get_service_scopes() {$services = lti_get_services(); $scopes = array(); foreach ($services as $service) { $servicescopes = $service->get_scopes(); if (!empty($servicescopes)) { $scopes = array_merge($scopes, $servicescopes); } } return $scopes; }> /** } > * Generates a new client id string. > * > * @return string generated client id > */ > public function new_clientid(): string { > return random_string(15); > } > > /** > * Base64 encoded signature for LTI 1.1 migration. > * @param string $key LTI 1.1 key > * @param string $salt Salt value > * @param string $secret LTI 1.1 secret > * > * @return string base64encoded hash > */ > public function sign(string $key, string $salt, string $secret): string { > return base64_encode(hash_hmac('sha-256', $key.$salt, $secret, true)); > } > > /** > * Returns a tool proxy > * > * @param int $proxyid > * > * @return mixed Tool Proxy details > */ > public function get_tool_proxy(int $proxyid) : array { > return lti_get_tool_proxy($proxyid); > }