Skip to content

Commit

Permalink
MDL-66920 mod_lti: Allow usage of both JWKS URI and Public Key
Browse files Browse the repository at this point in the history
- Changed mod_lti edit_form.php to add necessary fields.
- Added configuration field 'keytype' that can be RSA_KEY or JWK_KEYSET, defaulting to RSA_KEY if none is found.
- Changed mod_lti locallib.php to add the usage of jwk in the verifications of jwt's.
- Changed mod_lti token.php to call the verification function from locallib.php.
- Caches the keyset endpoint content of any given lti tool.
- Updated language files to accommodate new functionalities.
- Added test method for JWK functionalities.
- Added test_keyset file in the fixtures folder.
- Bumped the mod_lti version to 2020022200.
  • Loading branch information
Cvmcosta committed Apr 16, 2020
1 parent a2a13f2 commit 3259178
Show file tree
Hide file tree
Showing 8 changed files with 181 additions and 20 deletions.
32 changes: 32 additions & 0 deletions mod/lti/db/caches.php
@@ -0,0 +1,32 @@
<?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/>.

/**
* This file contains the cache definitions for the lti plugin
*
* @package mod_lti
* @copyright 2020 Carlos Vinícius Monteiro Costa
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/

defined('MOODLE_INTERNAL') || die();

// Added definition for keyset cache.
$definitions = [
'keyset' => [
'mode' => cache_store::MODE_APPLICATION
]
];
20 changes: 19 additions & 1 deletion mod/lti/edit_form.php
Expand Up @@ -129,12 +129,30 @@ public function definition() {
$mform->setType('lti_clientid', PARAM_TEXT);
}

$mform->addElement('textarea', 'lti_publickey', get_string('publickey', 'lti'), array('rows' => 8, 'cols' => 60));
$keyoptions = [
LTI_RSA_KEY => get_string('keytype_rsa', 'lti'),
LTI_JWK_KEYSET => get_string('keytype_keyset', 'lti'),
];
$mform->addElement('select', 'lti_keytype', get_string('keytype', 'lti'), $keyoptions);
$mform->setType('lti_keytype', PARAM_TEXT);
$mform->addHelpButton('lti_keytype', 'keytype', 'lti');
$mform->setDefault('lti_keytype', LTI_JWK_KEYSET);
$mform->hideIf('lti_keytype', 'lti_ltiversion', 'neq', LTI_VERSION_1P3);

$mform->addElement('textarea', 'lti_publickey', get_string('publickey', 'lti'), ['rows' => 8, 'cols' => 60]);
$mform->setType('lti_publickey', PARAM_TEXT);
$mform->addHelpButton('lti_publickey', 'publickey', 'lti');
$mform->hideIf('lti_publickey', 'lti_keytype', 'neq', LTI_RSA_KEY);
$mform->hideIf('lti_publickey', 'lti_ltiversion', 'neq', LTI_VERSION_1P3);
$mform->setForceLtr('lti_publickey');

$mform->addElement('text', 'lti_publickeyset', get_string('publickeyset', 'lti'), ['size' => '64']);
$mform->setType('lti_publickeyset', PARAM_TEXT);
$mform->addHelpButton('lti_publickeyset', 'publickeyset', 'lti');
$mform->hideIf('lti_publickeyset', 'lti_keytype', 'neq', LTI_JWK_KEYSET);
$mform->hideIf('lti_publickeyset', 'lti_ltiversion', 'neq', LTI_VERSION_1P3);
$mform->setForceLtr('lti_publickeyset');

$mform->addElement('text', 'lti_initiatelogin', get_string('initiatelogin', 'lti'), array('size' => '64'));
$mform->setType('lti_initiatelogin', PARAM_URL);
$mform->addHelpButton('lti_initiatelogin', 'initiatelogin', 'lti');
Expand Down
7 changes: 7 additions & 0 deletions mod/lti/lang/en/lti.php
Expand Up @@ -86,6 +86,7 @@
$string['basicltiintro'] = 'Activity description';
$string['basicltiname'] = 'Activity name';
$string['basicltisettings'] = 'Basic Learning Tool Interoperability (LTI) settings';
$string['cachedef_keyset'] = 'Caches the keyset information of tools';
$string['cancel'] = 'Cancel';
$string['cancelled'] = 'Cancelled';
$string['cannot_delete'] = 'You may not delete this tool configuration.';
Expand Down Expand Up @@ -230,6 +231,10 @@
$string['initiatelogin_help'] = 'The tool URL to which requests for initiating a login are to be sent. This URL is required before a message can be successfully sent to the tool.';
$string['invalidid'] = 'LTI ID was incorrect';
$string['jwtsecurity'] = 'LTI 1.3';
$string['keytype'] = 'Public key type';
$string['keytype_help'] = 'The authentication method used to validate the tool.';
$string['keytype_keyset'] = 'Keyset Url';
$string['keytype_rsa'] = 'RSA Key';
$string['launch_in_moodle'] = 'Launch tool in Moodle';
$string['launch_in_popup'] = 'Launch tool in a pop-up';
$string['launch_url'] = 'Tool URL';
Expand Down Expand Up @@ -398,6 +403,8 @@
$string['privacy:metadata:useridnumber'] = 'The ID number of the user accessing the LTI Consumer';
$string['privacy:metadata:username'] = 'The username of the user accessing the LTI Consumer';
$string['publickey'] = 'Public key';
$string['publickeyset'] = 'Public keyset';
$string['publickeyset_help'] = 'Public keyset from where moodle will retrieve the tool\'s public key to allow signatures of incoming messages and service requests to be verified.';
$string['publickey_help'] = 'The public key (in PEM format) provided by the tool to allow signatures of incoming messages and service requests to be verified.';
$string['quickgrade'] = 'Allow quick grading';
$string['quickgrade_help'] = 'If enabled, multiple tools can be graded on one page. Add grades and comments then click the "Save all my feedback" button to save all changes for that page.';
Expand Down
73 changes: 67 additions & 6 deletions mod/lti/locallib.php
Expand Up @@ -52,7 +52,8 @@

// TODO: Switch to core oauthlib once implemented - MDL-30149.
use moodle\mod\lti as lti;
use Firebase\JWT\JWT as JWT;
use Firebase\JWT\JWT;
use Firebase\JWT\JWK;

global $CFG;
require_once($CFG->dirroot.'/mod/lti/OAuth.php');
Expand Down Expand Up @@ -90,6 +91,8 @@
define('LTI_VERSION_1', 'LTI-1p0');
define('LTI_VERSION_2', 'LTI-2p0');
define('LTI_VERSION_1P3', '1.3.0');
define('LTI_RSA_KEY', 'RSA_KEY');
define('LTI_JWK_KEYSET', 'JWK_KEYSET');

define('LTI_DEFAULT_ORGID_SITEID', 'SITEID');
define('LTI_DEFAULT_ORGID_SITEHOST', 'SITEHOST');
Expand Down Expand Up @@ -1319,6 +1322,45 @@ function lti_verify_oauth_signature($typeid, $consumerkey) {
return $tool;
}

/**
* Verifies the JWT signature using a JWK keyset.
*
* @param string $jwtparam JWT parameter value.
* @param string $keyseturl The tool keyseturl.
* @param string $clientid The tool client id.
*
* @return object The JWT's payload as a PHP object
* @throws moodle_exception
* @throws UnexpectedValueException Provided JWT was invalid
* @throws SignatureInvalidException Provided JWT was invalid because the signature verification failed
* @throws BeforeValidException Provided JWT is trying to be used before it's eligible as defined by 'nbf'
* @throws BeforeValidException Provided JWT is trying to be used before it's been created as defined by 'iat'
* @throws ExpiredException Provided JWT has since expired, as defined by the 'exp' claim
*/
function lti_verify_with_keyset($jwtparam, $keyseturl, $clientid) {
// Attempts to retrieve cached keyset.
$cache = cache::make('mod_lti', 'keyset');
$keyset = $cache->get($clientid);

try {
if (empty($keyset)) {
throw new moodle_exception('errornocachedkeysetfound', 'mod_lti');
}
$keysetarr = json_decode($keyset, true);
$keys = JWK::parseKeySet($keysetarr);
$jwt = JWT::decode($jwtparam, $keys, ['RS256']);
} catch (Exception $e) {
// Something went wrong, so attempt to update cached keyset and then try again.
$keyset = file_get_contents($keyseturl);
$keysetarr = json_decode($keyset, true);
$keys = JWK::parseKeySet($keysetarr);
$jwt = JWT::decode($jwtparam, $keys, ['RS256']);
// If sucessful, updates the cached keyset.
$cache->set($clientid, $keyset);
}
return $jwt;
}

/**
* Verifies the JWT signature of an incoming message.
*
Expand All @@ -1336,6 +1378,7 @@ function lti_verify_oauth_signature($typeid, $consumerkey) {
*/
function lti_verify_jwt_signature($typeid, $consumerkey, $jwtparam) {
$tool = lti_get_type($typeid);

// Validate parameters.
if (!$tool) {
throw new moodle_exception('errortooltypenotfound', 'mod_lti');
Expand All @@ -1347,16 +1390,28 @@ function lti_verify_jwt_signature($typeid, $consumerkey, $jwtparam) {
$typeconfig = lti_get_type_config($typeid);

$key = $tool->clientid ?? '';
$publickey = $typeconfig['publickey'] ?? '';

if ($consumerkey !== $key) {
throw new moodle_exception('errorincorrectconsumerkey', 'mod_lti');
}
if (empty($publickey)) {
throw new moodle_exception('No public key configured');
}

JWT::decode($jwtparam, $publickey, array('RS256'));
if (empty($typeconfig['keytype']) || $typeconfig['keytype'] === LTI_RSA_KEY) {
$publickey = $typeconfig['publickey'] ?? '';
if (empty($publickey)) {
throw new moodle_exception('No public key configured');
}
// Attemps to verify jwt with RSA key.
JWT::decode($jwtparam, $publickey, ['RS256']);
} else if ($typeconfig['keytype'] === LTI_JWK_KEYSET) {
$keyseturl = $typeconfig['publickeyset'] ?? '';
if (empty($keyseturl)) {
throw new moodle_exception('No public keyset configured');
}
// Attempts to verify jwt with jwk keyset.
lti_verify_with_keyset($jwtparam, $keyseturl, $tool->clientid);
} else {
throw new moodle_exception('Invalid public key type');
}

return $tool;
}
Expand Down Expand Up @@ -2476,6 +2531,12 @@ function lti_get_type_type_config($id) {
if (isset($config['publickey'])) {
$type->lti_publickey = $config['publickey'];
}
if (isset($config['publickeyset'])) {
$type->lti_publickeyset = $config['publickeyset'];
}
if (isset($config['keytype'])) {
$type->lti_keytype = $config['keytype'];
}
if (isset($config['initiatelogin'])) {
$type->lti_initiatelogin = $config['initiatelogin'];
}
Expand Down
1 change: 1 addition & 0 deletions mod/lti/tests/fixtures/test_keyset
@@ -0,0 +1 @@
{"keys":[{"kty":"RSA","kid":"701feb7a2901164add6576bfced23510","n":"tFqL_TBjryeXRp4SMLxpW7cDWuw9nag1tN8m3aLRnHj9SECzavBdOQIlXiPeKIV2i95TCTdFAxdjIoDXGqy_MUX0BRdQrWHA4pF-4bj3WgciwieJ9AVV1QH8dEgkV8vlSWQ9vuD0qYsr24ZfznMtKXXdToLtwN6Za1c0RtIJC1s8rSFIaFEQ8EZW0IgJYvYn-HJvbBL0ZZXBetTb-kKHAdhqWJs8MooehG9OjB7bvur25uc_Q32NfMvMYEA-oWcs9n5PuxgmCAgQHAHzQH4l0oWwF7nnCddhNGskIUGT4VqtbZU-ZSr3jqXg5eHhcKowP-Yl32ugUm1kKzf5RDZQG7Ci4bkhaCzliBFUpiaSezXOqeVe2rgq0pBBJZFjL6ECCWLDQMzYzUtZrAIt3qQeTcVnEgmbXQmXjPbpF5rj7xYeyZoMKY5Qe1NUZNEdFFuODIA6PZFUmOO3tUwZs9Zmk0OUX9dZzlJIa-cyyez0kben_MLnZ64T4Z3dPDzI2rmCJGoexzDXDFb-_bAZTVdGbohDBkkBEnBG2jjBnWlZdjzuRGENDkSKw8lVm2g-uc4cGIkdLfW8BpGeIOZsT3A7-o5R3D0U7hlykd-weF99QF_ZcflE71iZN80u_J-xB7DA2pdTi7TF6yDjEFaG9kYYgmvabx2qTKIcfAkOKVP4YvU","e":"AQAB","alg":"RS256","use":"sig"},{"kty":"RSA","kid":"57c1177d2d53a021c73756491c137b17","n":"tkeuoQIsfQzW8_wrmI4qCLPYccqNc9iMD5_uwy6JTVx6PQAIwlSGeAPkWpxV9RJmXKWhZ6dMxZ-vCEPqDSMI3IIvYPdVOuu-jdlxFtGfodIu0R1Nk38Q4TPnBQ3WXKaBvwpsBLdoURiHxAprFIyLy4m95-e5qB3dW0kFYbtbSHz3rz28byJ3t0SQBlSO36f2uBbn3jWC3-IMkIFiST6Ndvdj0Z7Q08qALXWG3k6R5y8oEJpNrxhyUgJypeCKsMt938tNBPDGXNVyo0dUK6DL2jJX7UpNCmv62mblfDiGrh4LCJJEcY-Mn2EtGhYczRGYVOhq8-7_GfYa7Dor7fzi-57M0cJ-ROB99YwQ415XcE-wnhSKy8Wr_K8CEPK04o8l5mUraBwRIm7N3hfMC8kez7Pu7mcA9u5Z1V5wtj6ltztZhTKjJVe-azurLjVpz8zbKl_tc6rD-mkhaXpyFTStk0jf9uYVf6fsEq3btPoxmNZgpNW1FeB5ied5ndhydDVtj8cSl4vVc4PzTKwHb99EcVC7ka_327dp7wE3ewMkPinxdWVUrtilWglVUOO6O9K6iOr5e0zHFw7l7-LMOod6mqj4RfWqvvZRaUsB89WMTvT0i5Y-Wx0ysidIEKNfFekBLOb57eb160ysoEdPfVNQ7nCJHlLjVxwO0Ez1OOwnnIE","e":"AQAB","alg":"RS256","use":"sig"}]}
44 changes: 44 additions & 0 deletions mod/lti/tests/locallib_test.php
Expand Up @@ -1078,6 +1078,8 @@ public function test_lti_verify_jwt_signature() {
MwIDAQAB
-----END PUBLIC KEY-----';

$config->lti_keytype = LTI_RSA_KEY;

$typeid = lti_add_type($type, $config);

lti_verify_jwt_signature($typeid, '', 'eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4g' .
Expand All @@ -1087,6 +1089,46 @@ public function test_lti_verify_jwt_signature() {
'v7tuPWBFfEbLxtF2pZS6YC1aSfLQxeNe8djT9YjpvRZA');
}

/**
* Test lti_verify_jwt_signature_jwk().
*/
public function test_lti_verify_jwt_signature_jwk() {
$this->resetAfterTest();

$this->setAdminUser();

// Create a tool type, associated with that proxy.
$type = new stdClass();
$type->state = LTI_TOOL_STATE_CONFIGURED;
$type->name = "Test tool";
$type->description = "Example description";
$type->baseurl = $this->getExternalTestFileUrl('/test.html');

$config = new stdClass();
$config->lti_publickeyset = dirname(__FILE__) . '/fixtures/test_keyset';

$config->lti_keytype = LTI_JWK_KEYSET;

$typeid = lti_add_type($type, $config);

$jwt = 'eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6IjU3YzExNzdkMmQ1M2EwMjFjNzM';
$jwt .= '3NTY0OTFjMTM3YjE3In0.eyJpc3MiOiJnclJvbkd3RTd1WjRwZ28iLCJzdWIiOiJnclJvb';
$jwt .= 'kd3RTd1WjRwZ28iLCJhdWQiOiJodHRwOi8vbG9jYWxob3N0L21vb2RsZS9tb2QvbHRpL3R';
$jwt .= 'va2VuLnBocCIsImp0aSI6IjFlMUJPVEczVFJjbFdUem00dERsMGc9PSIsImlhdCI6MTU4M';
$jwt .= 'Dg1NTUwNX0.Lowhc9ovNAXRb2rkAnv1oozDXlRD54Mz2JS1i8Zx4yGWQzmXzam-La19_g0';
$jwt .= 'CTnwlKM6gxaInnRKFRAcwhJVcWec389liLAjMbna6d6iTWYTZr7q_4BIe3CT_oTMWASGta';
$jwt .= 'Paaq53ch1rO4YdueEtmtd1K47ibo4Lhu1jmP_icc3lxjfnqiv4vIYdy7W2JQEzpk1ImuQr';
$jwt .= 'AlO1xR3fZ6bgcJhVIaw5xoaZD3ZgEjuZOQXMkywv1bL-mL17RX336CzHd8rYZg82QXrBzb';
$jwt .= 'NWzAlaZxv9VSug8t6mORvM6TkYYWjqEBKemgkD5rNh1BHrPcjWP7vy2Jz7YMjLsmuvDuLK';
$jwt .= '_PHYIKL--s4gcXWoYmOu1vj-SgoPczTJPoiBD35hAKqVHy5ggHaYHBy95_bbcFd8H1smHw';
$jwt .= 'pejrAFj1QAwGyTISLzUm08oq7Ak0tSxRKKXw4lpZAka1MmYxO3tJ_3-MXw6Bwz12bNgitJ';
$jwt .= 'lQd6n3kkGLCJAmANeRkPsH6eZVwF0n2cjh2O1JAwyNcMD2vs4I8ftM1EqqoE2M3r6kt3AC';
$jwt .= 'EscmqzizI3j80USBCLUUb1UTsfJb2g7oyApJAp-13Q3InR3QyvWO8unG5VraFE7IL5I28h';
$jwt .= 'MkQAHuCI90DFmXB4leflAu7wNlIK_U8xkGl8X8Mnv6MWgg94Ki8jgIq_kA85JAqI';

lti_verify_jwt_signature($typeid, '', $jwt);
}

/**
* Test lti_verify_jwt_signature().
*/
Expand Down Expand Up @@ -1155,6 +1197,7 @@ public function test_lti_verify_jwt_signature_no_public_key() {
$type->baseurl = $this->getExternalTestFileUrl('/test.html');

$config = new stdClass();
$config->lti_keytype = LTI_RSA_KEY;
$typeid = lti_add_type($type, $config);

$this->expectExceptionMessage('No public key configured');
Expand Down Expand Up @@ -1272,6 +1315,7 @@ public function test_lti_convert_from_jwt() {
V6L11BWkpzGXSW4Hv43qa+GSYOD2QU68Mb59oSk2OB+BtOLpJofmbGEGgvmwyCI9
MwIDAQAB
-----END PUBLIC KEY-----';
$config->lti_keytype = LTI_RSA_KEY;

$typeid = lti_add_type($type, $config);

Expand Down
22 changes: 10 additions & 12 deletions mod/lti/token.php
Expand Up @@ -25,12 +25,11 @@
define('NO_DEBUG_DISPLAY', true);
define('NO_MOODLE_COOKIES', true);

use Firebase\JWT\JWT as JWT;
use Firebase\JWT\JWT;

require_once(__DIR__ . '/../../config.php');
require_once($CFG->dirroot . '/mod/lti/locallib.php');


$response = new \mod_lti\local\ltiservice\response();

$contenttype = isset($_SERVER['CONTENT_TYPE']) ? explode(';', $_SERVER['CONTENT_TYPE'], 2)[0] : '';
Expand Down Expand Up @@ -66,26 +65,25 @@
}

if ($ok) {
$error = 'invalid_client';
$tool = $DB->get_record('lti_types', array('clientid' => $claims['sub']));
if ($tool) {
$typeconfig = lti_get_type_config($tool->id);
if (!empty($typeconfig['publickey'])) {
try {
$jwt = JWT::decode($clientassertion, $typeconfig['publickey'], array('RS256'));
$ok = true;
} catch (Exception $e) {
$ok = false;
}
try {
lti_verify_jwt_signature($tool->id, $claims['sub'], $clientassertion);
$ok = true;
} catch (Exception $e) {
$error = $e->getMessage();
$ok = false;
}
} else {
$error = 'invalid_client';
$ok = false;
}
}

if ($ok) {
$scopes = array();
$requestedscopes = explode(' ', $scope);
$typeconfig = lti_get_type_config($tool->id);
$permittedscopes = lti_get_permitted_service_scopes($tool, $typeconfig);
$scopes = array_intersect($requestedscopes, $permittedscopes);
$ok = !empty($scopes);
Expand Down Expand Up @@ -115,4 +113,4 @@

$response->set_body($body);

$response->send();
$response->send();
2 changes: 1 addition & 1 deletion mod/lti/version.php
Expand Up @@ -48,7 +48,7 @@

defined('MOODLE_INTERNAL') || die;

$plugin->version = 2020010800; // The current module version (Date: YYYYMMDDXX).
$plugin->version = 2020022200; // The current module version (Date: YYYYMMDDXX).
$plugin->requires = 2019111200; // Requires this Moodle version.
$plugin->component = 'mod_lti'; // Full name of the plugin (used for diagnostics).
$plugin->cron = 0;

0 comments on commit 3259178

Please sign in to comment.