MDL-66920 mod_lti: Allow usage of both JWKS URI and Public Key
authorcarlos <cvmcosta@yahoo.com.br>
Fri, 3 Apr 2020 14:42:53 +0000 (11:42 -0300)
committercarlos <cvmcosta@yahoo.com.br>
Thu, 16 Apr 2020 11:53:53 +0000 (08:53 -0300)
- 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.

mod/lti/db/caches.php [new file with mode: 0644]
mod/lti/edit_form.php
mod/lti/lang/en/lti.php
mod/lti/locallib.php
mod/lti/tests/fixtures/test_keyset [new file with mode: 0644]
mod/lti/tests/locallib_test.php
mod/lti/token.php
mod/lti/version.php

diff --git a/mod/lti/db/caches.php b/mod/lti/db/caches.php
new file mode 100644 (file)
index 0000000..9d4f4e7
--- /dev/null
@@ -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
+    ]
+];
\ No newline at end of file
index 85507c0..83361f5 100644 (file)
@@ -129,12 +129,30 @@ class mod_lti_edit_types_form extends moodleform {
                 $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');
index 4995d5c..1d1284b 100644 (file)
@@ -86,6 +86,7 @@ $string['basicltifieldset'] = 'Custom example fieldset';
 $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.';
@@ -230,6 +231,10 @@ $string['initiatelogin'] = 'Initiate login URL';
 $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';
@@ -398,6 +403,8 @@ $string['privacy:metadata:userid'] = 'The ID of the user accessing the LTI Consu
 $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.';
index 7632f47..da61f6e 100644 (file)
@@ -52,7 +52,8 @@ defined('MOODLE_INTERNAL') || die;
 
 // 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');
@@ -90,6 +91,8 @@ define('LTI_COURSEVISIBLE_ACTIVITYCHOOSER', 2);
 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');
@@ -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.
  *
@@ -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');
@@ -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;
 }
@@ -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'];
     }
diff --git a/mod/lti/tests/fixtures/test_keyset b/mod/lti/tests/fixtures/test_keyset
new file mode 100644 (file)
index 0000000..6d8f60e
--- /dev/null
@@ -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"}]}
\ No newline at end of file
index 2b2f59e..1a9800a 100644 (file)
@@ -1078,6 +1078,8 @@ V6L11BWkpzGXSW4Hv43qa+GSYOD2QU68Mb59oSk2OB+BtOLpJofmbGEGgvmwyCI9
 MwIDAQAB
 -----END PUBLIC KEY-----';
 
+        $config->lti_keytype = LTI_RSA_KEY;
+
         $typeid = lti_add_type($type, $config);
 
         lti_verify_jwt_signature($typeid, '', 'eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4g' .
@@ -1087,6 +1089,46 @@ MwIDAQAB
             '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().
      */
@@ -1155,6 +1197,7 @@ MwIDAQAB
         $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');
@@ -1272,6 +1315,7 @@ e+lf4s4OxQawWD79J9/5d3Ry0vbV3Am1FtGJiJvOwRsIfVChDpYStTcHTCMqtvWb
 V6L11BWkpzGXSW4Hv43qa+GSYOD2QU68Mb59oSk2OB+BtOLpJofmbGEGgvmwyCI9
 MwIDAQAB
 -----END PUBLIC KEY-----';
+        $config->lti_keytype = LTI_RSA_KEY;
 
         $typeid = lti_add_type($type, $config);
 
index 3d90ca8..1dc2abb 100644 (file)
 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] : '';
@@ -66,19 +65,17 @@ if ($ok) {
 }
 
 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;
     }
 }
@@ -86,6 +83,7 @@ if ($ok) {
 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);
@@ -115,4 +113,4 @@ EOD;
 
 $response->set_body($body);
 
-$response->send();
+$response->send();
\ No newline at end of file
index 42ae637..e26af0d 100644 (file)
@@ -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;