diff --git a/composer.json b/composer.json index 804cdc3b..9efabab0 100644 --- a/composer.json +++ b/composer.json @@ -2,7 +2,7 @@ "name": "imsglobal/lti-1p3-tool", "type": "library", "require": { - "fproject/php-jwt": "^4.0", + "firebase/php-jwt": "^5.2", "phpseclib/phpseclib": "^2.0" }, "autoload": { diff --git a/src/lti/Cache.php b/src/lti/Cache.php index 0d865711..f8c13c4b 100644 --- a/src/lti/Cache.php +++ b/src/lti/Cache.php @@ -1,46 +1,9 @@ load_cache(); - return $this->cache[$key]; - } - - public function cache_launch_data($key, $jwt_body) { - $this->cache[$key] = $jwt_body; - $this->save_cache(); - return $this; - } - - public function cache_nonce($nonce) { - $this->cache['nonce'][$nonce] = true; - $this->save_cache(); - return $this; - } - - public function check_nonce($nonce) { - $this->load_cache(); - if (!isset($this->cache['nonce'][$nonce])) { - return false; - } - return true; - } - - private function load_cache() { - $cache = file_get_contents(sys_get_temp_dir() . '/lti_cache.txt'); - if (empty($cache)) { - file_put_contents(sys_get_temp_dir() . '/lti_cache.txt', '{}'); - $this->cache = []; - } - $this->cache = json_decode($cache, true); - } - - private function save_cache() { - file_put_contents(sys_get_temp_dir() . '/lti_cache.txt', json_encode($this->cache)); - } +interface Cache { + public function get_launch_data($key); + public function cache_launch_data($key, $jwt_body); + public function cache_nonce($nonce); + public function check_nonce($nonce); } -?> \ No newline at end of file diff --git a/src/lti/Cookie.php b/src/lti/Cookie.php index e18700b6..1d588fec 100644 --- a/src/lti/Cookie.php +++ b/src/lti/Cookie.php @@ -1,34 +1,7 @@ time() + $exp - ]; - - // SameSite none and secure will be required for tools to work inside iframes - $same_site_options = [ - 'samesite' => 'None', - 'secure' => true - ]; - - setcookie($name, $value, array_merge($cookie_options, $same_site_options, $options)); - - // Set a second fallback cookie in the event that "SameSite" is not supported - setcookie("LEGACY_" . $name, $value, array_merge($cookie_options, $options)); - return $this; - } +interface Cookie { + public function get_cookie($name); + public function set_cookie($name, $value, $exp = 3600, $options = []); } -?> diff --git a/src/lti/Database.php b/src/lti/Database.php index 945afd67..d002fef8 100644 --- a/src/lti/Database.php +++ b/src/lti/Database.php @@ -6,4 +6,3 @@ public function find_registration_by_issuer($iss); public function find_deployment($iss, $deployment_id); } -?> \ No newline at end of file diff --git a/src/lti/ImsCache.php b/src/lti/ImsCache.php new file mode 100644 index 00000000..b52f71f5 --- /dev/null +++ b/src/lti/ImsCache.php @@ -0,0 +1,45 @@ +load_cache(); + return $this->cache[$key]; + } + + public function cache_launch_data($key, $jwt_body) { + $this->cache[$key] = $jwt_body; + $this->save_cache(); + return $this; + } + + public function cache_nonce($nonce) { + $this->cache['nonce'][$nonce] = true; + $this->save_cache(); + return $this; + } + + public function check_nonce($nonce) { + $this->load_cache(); + if (!isset($this->cache['nonce'][$nonce])) { + return false; + } + return true; + } + + private function load_cache() { + $cache = file_get_contents(sys_get_temp_dir() . '/lti_cache.txt'); + if (empty($cache)) { + file_put_contents(sys_get_temp_dir() . '/lti_cache.txt', '{}'); + $this->cache = []; + } + $this->cache = json_decode($cache, true); + } + + private function save_cache() { + file_put_contents(sys_get_temp_dir() . '/lti_cache.txt', json_encode($this->cache)); + } +} diff --git a/src/lti/ImsCookie.php b/src/lti/ImsCookie.php new file mode 100644 index 00000000..ee1feb35 --- /dev/null +++ b/src/lti/ImsCookie.php @@ -0,0 +1,33 @@ + time() + $exp + ]; + + // SameSite none and secure will be required for tools to work inside iframes + $same_site_options = [ + 'samesite' => 'None', + 'secure' => true + ]; + + setcookie($name, $value, array_merge($cookie_options, $same_site_options, $options)); + + // Set a second fallback cookie in the event that "SameSite" is not supported + setcookie("LEGACY_" . $name, $value, array_merge($cookie_options, $options)); + return $this; + } +} diff --git a/src/lti/LTI_Assignments_Grades_Service.php b/src/lti/LTI_Assignments_Grades_Service.php index ffd1cde0..816c8757 100644 --- a/src/lti/LTI_Assignments_Grades_Service.php +++ b/src/lti/LTI_Assignments_Grades_Service.php @@ -12,7 +12,7 @@ public function __construct(LTI_Service_Connector $service_connector, $service_d } public function put_grade(LTI_Grade $grade, LTI_Lineitem $lineitem = null) { - if (!in_array("https://purl.imsglobal.org/spec/lti-ags/scope/score", $this->service_data['scope'])) { + if (!in_array(LTI_Constants::AGS_SCORE, $this->service_data['scope'])) { throw new LTI_Exception('Missing required scope', 1); } $score_url = ''; @@ -42,7 +42,7 @@ public function put_grade(LTI_Grade $grade, LTI_Lineitem $lineitem = null) { } public function find_or_create_lineitem(LTI_Lineitem $new_line_item) { - if (!in_array("https://purl.imsglobal.org/spec/lti-ags/scope/lineitem", $this->service_data['scope'])) { + if (!in_array(LTI_Constants::AGS_LINEITEM, $this->service_data['scope'])) { throw new LTI_Exception('Missing required scope', 1); } $line_items = $this->service_connector->make_service_request( @@ -88,4 +88,3 @@ public function get_grades(LTI_Lineitem $lineitem) { return $scores['body']; } } -?> \ No newline at end of file diff --git a/src/lti/LTI_Constants.php b/src/lti/LTI_Constants.php new file mode 100644 index 00000000..80703cdf --- /dev/null +++ b/src/lti/LTI_Constants.php @@ -0,0 +1,77 @@ + time() + 600, "iat" => time(), "nonce" => 'nonce' . hash('sha256', random_bytes(64)), - "https://purl.imsglobal.org/spec/lti/claim/deployment_id" => $this->deployment_id, - "https://purl.imsglobal.org/spec/lti/claim/message_type" => "LtiDeepLinkingResponse", - "https://purl.imsglobal.org/spec/lti/claim/version" => "1.3.0", - "https://purl.imsglobal.org/spec/lti-dl/claim/content_items" => array_map(function($resource) { return $resource->to_array(); }, $resources), - "https://purl.imsglobal.org/spec/lti-dl/claim/data" => $this->deep_link_settings['data'], + LTI_Constants::DEPLOYMENT_ID => $this->deployment_id, + LTI_Constants::MESSAGE_TYPE => "LtiDeepLinkingResponse", + LTI_Constants::VERSION => LTI_Constants::V1_3, + LTI_Constants::DL_CONTENT_ITEMS => array_map(function($resource) { return $resource->to_array(); }, $resources), + LTI_Constants::DL_DATA => $this->deep_link_settings['data'], ]; return JWT::encode($message_jwt, $this->registration->get_tool_private_key(), 'RS256', $this->registration->get_kid()); } @@ -43,4 +43,3 @@ public function output_response_form($resources) { \ No newline at end of file diff --git a/src/lti/LTI_Deep_Link_Resource.php b/src/lti/LTI_Deep_Link_Resource.php index 8abeadd6..7b1d97ce 100644 --- a/src/lti/LTI_Deep_Link_Resource.php +++ b/src/lti/LTI_Deep_Link_Resource.php @@ -87,4 +87,4 @@ public function to_array() { return $resource; } } -?> + diff --git a/src/lti/LTI_Deployment.php b/src/lti/LTI_Deployment.php index ad502055..2b823831 100644 --- a/src/lti/LTI_Deployment.php +++ b/src/lti/LTI_Deployment.php @@ -20,4 +20,3 @@ public function set_deployment_id($deployment_id) { } -?> \ No newline at end of file diff --git a/src/lti/LTI_Exception.php b/src/lti/LTI_Exception.php index b54bf7d3..8cd9f27e 100644 --- a/src/lti/LTI_Exception.php +++ b/src/lti/LTI_Exception.php @@ -4,4 +4,3 @@ class LTI_Exception extends \Exception { } -?> \ No newline at end of file diff --git a/src/lti/LTI_Grade.php b/src/lti/LTI_Grade.php index fabeb6ad..e256bcd0 100644 --- a/src/lti/LTI_Grade.php +++ b/src/lti/LTI_Grade.php @@ -103,4 +103,3 @@ public function __toString() { ])); } } -?> \ No newline at end of file diff --git a/src/lti/LTI_Lineitem.php b/src/lti/LTI_Lineitem.php index ba8e20a7..3b01269b 100644 --- a/src/lti/LTI_Lineitem.php +++ b/src/lti/LTI_Lineitem.php @@ -105,4 +105,3 @@ public function __toString() { ])); } } -?> \ No newline at end of file diff --git a/src/lti/LTI_Message_Launch.php b/src/lti/LTI_Message_Launch.php index 7a18195d..9bb7cebc 100644 --- a/src/lti/LTI_Message_Launch.php +++ b/src/lti/LTI_Message_Launch.php @@ -3,6 +3,7 @@ use Firebase\JWT\JWK; use Firebase\JWT\JWT; +use Firebase\JWT\ExpiredException; JWT::$leeway = 5; @@ -29,12 +30,12 @@ function __construct(Database $database, Cache $cache = null, Cookie $cookie = n $this->launch_id = uniqid("lti1p3_launch_", true); if ($cache === null) { - $cache = new Cache(); + $cache = new ImsCache(); } $this->cache = $cache; if ($cookie === null) { - $cookie = new Cookie(); + $cookie = new ImsCookie(); } $this->cookie = $cookie; } @@ -94,7 +95,7 @@ public function validate(array $request = null) { * @return boolean Returns a boolean indicating the availability of names and roles. */ public function has_nrps() { - return !empty($this->jwt['body']['https://purl.imsglobal.org/spec/lti-nrps/claim/namesroleservice']['context_memberships_url']); + return !empty($this->jwt['body'][LTI_Constants::NRPS_NAMESROLESPROVISIONINGSERVICE]['context_memberships_url']); } /** @@ -105,7 +106,7 @@ public function has_nrps() { public function get_nrps() { return new LTI_Names_Roles_Provisioning_Service( new LTI_Service_Connector($this->registration), - $this->jwt['body']['https://purl.imsglobal.org/spec/lti-nrps/claim/namesroleservice']); + $this->jwt['body'][LTI_Constants::NRPS_NAMESROLESPROVISIONINGSERVICE]); } /** @@ -134,7 +135,7 @@ public function get_gs() { * @return boolean Returns a boolean indicating the availability of assignments and grades. */ public function has_ags() { - return !empty($this->jwt['body']['https://purl.imsglobal.org/spec/lti-ags/claim/endpoint']); + return !empty($this->jwt['body'][LTI_Constants::AGS_ENDPOINT]); } /** @@ -145,7 +146,7 @@ public function has_ags() { public function get_ags() { return new LTI_Assignments_Grades_Service( new LTI_Service_Connector($this->registration), - $this->jwt['body']['https://purl.imsglobal.org/spec/lti-ags/claim/endpoint']); + $this->jwt['body'][LTI_Constants::AGS_ENDPOINT]); } /** @@ -156,8 +157,8 @@ public function get_ags() { public function get_deep_link() { return new LTI_Deep_Link( $this->registration, - $this->jwt['body']['https://purl.imsglobal.org/spec/lti/claim/deployment_id'], - $this->jwt['body']['https://purl.imsglobal.org/spec/lti-dl/claim/deep_linking_settings']); + $this->jwt['body'][LTI_Constants::DEPLOYMENT_ID], + $this->jwt['body'][LTI_Constants::DL_DEEP_LINK_SETTINGS]); } /** @@ -166,7 +167,7 @@ public function get_deep_link() { * @return boolean Returns true if the current launch is a deep linking launch. */ public function is_deep_link_launch() { - return $this->jwt['body']['https://purl.imsglobal.org/spec/lti/claim/message_type'] === 'LtiDeepLinkingRequest'; + return $this->jwt['body'][LTI_Constants::MESSAGE_TYPE] === 'LtiDeepLinkingRequest'; } /** @@ -184,7 +185,7 @@ public function is_submission_review_launch() { * @return boolean Returns true if the current launch is a resource launch. */ public function is_resource_launch() { - return $this->jwt['body']['https://purl.imsglobal.org/spec/lti/claim/message_type'] === 'LtiResourceLinkRequest'; + return $this->jwt['body'][LTI_Constants::MESSAGE_TYPE] === 'LtiResourceLinkRequest'; } /** @@ -220,7 +221,11 @@ private function get_public_key() { foreach ($public_key_set['keys'] as $key) { if ($key['kid'] == $this->jwt['header']['kid']) { try { - return openssl_pkey_get_details(JWK::parseKey($key)); + return openssl_pkey_get_details( + JWK::parseKeySet([ + 'keys' => [$key] + ])[$key['kid']] + ); } catch(\Exception $e) { return false; } @@ -269,6 +274,9 @@ private function validate_jwt_format() { } private function validate_nonce() { + if (!isset($this->jwt['body']['nonce'])) { + throw new LTI_Exception("Missing Nonce"); + } if (!$this->cache->check_nonce($this->jwt['body']['nonce'])) { //throw new LTI_Exception("Invalid Nonce"); } @@ -294,14 +302,17 @@ private function validate_registration() { } private function validate_jwt_signature() { + if (!isset($this->jwt['header']['kid'])) { + throw new LTI_Exception("No KID specified in the JWT Header"); + } + // Fetch public key. $public_key = $this->get_public_key(); // Validate JWT signature try { JWT::decode($this->request['id_token'], $public_key['key'], array('RS256')); - } catch(\Exception $e) { - var_dump($e); + } catch(ExpiredException $e) { // Error validating signature. throw new LTI_Exception("Invalid signature on id_token", 1); } @@ -310,8 +321,12 @@ private function validate_jwt_signature() { } private function validate_deployment() { + if (!isset($this->jwt['body'][LTI_Constants::DEPLOYMENT_ID])) { + throw new LTI_Exception("No deployment ID was specified", 1); + } + // Find deployment. - $deployment = $this->db->find_deployment($this->jwt['body']['iss'], $this->jwt['body']['https://purl.imsglobal.org/spec/lti/claim/deployment_id']); + $deployment = $this->db->find_deployment($this->jwt['body']['iss'], $this->jwt['body'][LTI_Constants::DEPLOYMENT_ID]); if (empty($deployment)) { // deployment not recognized. @@ -322,7 +337,7 @@ private function validate_deployment() { } private function validate_message() { - if (empty($this->jwt['body']['https://purl.imsglobal.org/spec/lti/claim/message_type'])) { + if (empty($this->jwt['body'][LTI_Constants::MESSAGE_TYPE])) { // Unable to identify message type. throw new LTI_Exception("Invalid message type", 1); } @@ -369,4 +384,4 @@ private function validate_message() { } } -?> \ No newline at end of file + diff --git a/src/lti/LTI_Names_Roles_Provisioning_Service.php b/src/lti/LTI_Names_Roles_Provisioning_Service.php index df2242ea..e22b594d 100644 --- a/src/lti/LTI_Names_Roles_Provisioning_Service.php +++ b/src/lti/LTI_Names_Roles_Provisioning_Service.php @@ -1,44 +1,43 @@ -service_connector = $service_connector; - $this->service_data = $service_data; - } - - public function get_members() { - - $members = []; - - $next_page = $this->service_data['context_memberships_url']; - - while ($next_page) { - $page = $this->service_connector->make_service_request( - ['https://purl.imsglobal.org/spec/lti-nrps/scope/contextmembership.readonly'], - 'GET', - $next_page, - null, - null, - 'application/vnd.ims.lti-nrps.v2.membershipcontainer+json' - ); - - $members = array_merge($members, $page['body']['members']); - - $next_page = false; - foreach($page['headers'] as $header) { - if (preg_match(LTI_Service_Connector::NEXT_PAGE_REGEX, $header, $matches)) { - $next_page = $matches[1]; - break; - } - } - } - return $members; - - } -} -?> +service_connector = $service_connector; + $this->service_data = $service_data; + } + + public function get_members() { + + $members = []; + + $next_page = $this->service_data['context_memberships_url']; + + while ($next_page) { + $page = $this->service_connector->make_service_request( + [LTI_Constants::NRPS_CONTEXT_MEMBERSHIP_READ_ONLY], + 'GET', + $next_page, + null, + null, + 'application/vnd.ims.lti-nrps.v2.membershipcontainer+json' + ); + + $members = array_merge($members, $page['body']['members']); + + $next_page = false; + foreach($page['headers'] as $header) { + if (preg_match(LTI_Service_Connector::NEXT_PAGE_REGEX, $header, $matches)) { + $next_page = $matches[1]; + break; + } + } + } + return $members; + + } +} diff --git a/src/lti/LTI_OIDC_Login.php b/src/lti/LTI_OIDC_Login.php index 2c135c09..4c43aa66 100644 --- a/src/lti/LTI_OIDC_Login.php +++ b/src/lti/LTI_OIDC_Login.php @@ -3,6 +3,8 @@ class LTI_OIDC_Login { + public const COOKIE_PREFIX = 'lti1p3_'; + private $db; private $cache; private $cookie; @@ -17,12 +19,12 @@ class LTI_OIDC_Login { function __construct(Database $database, Cache $cache = null, Cookie $cookie = null) { $this->db = $database; if ($cache === null) { - $cache = new Cache(); + $cache = new ImsCache(); } $this->cache = $cache; if ($cookie === null) { - $cookie = new Cookie(); + $cookie = new ImsCookie(); } $this->cookie = $cookie; } @@ -62,7 +64,7 @@ public function do_oidc_login_redirect($launch_url, array $request = null) { // Generate State. // Set cookie (short lived) $state = str_replace('.', '_', uniqid('state-', true)); - $this->cookie->set_cookie("lti1p3_$state", $state, 60); + $this->cookie->set_cookie(static::COOKIE_PREFIX.$state, $state, 60); // Generate Nonce. $nonce = uniqid('nonce-', true); @@ -117,4 +119,4 @@ protected function validate_oidc_login($request) { // Return Registration. return $registration; } -} \ No newline at end of file +} diff --git a/src/lti/LTI_Registration.php b/src/lti/LTI_Registration.php index 7438253c..3a5bbac2 100644 --- a/src/lti/LTI_Registration.php +++ b/src/lti/LTI_Registration.php @@ -90,4 +90,3 @@ public function set_kid($kid) { } -?> \ No newline at end of file diff --git a/src/lti/LTI_Service_Connector.php b/src/lti/LTI_Service_Connector.php index cd2318e5..9863606a 100644 --- a/src/lti/LTI_Service_Connector.php +++ b/src/lti/LTI_Service_Connector.php @@ -90,4 +90,3 @@ public function make_service_request($scopes, $method, $url, $body = null, $cont ]; } } -?> \ No newline at end of file diff --git a/src/lti/Message_Validator.php b/src/lti/Message_Validator.php index d12be20b..6ef79e46 100644 --- a/src/lti/Message_Validator.php +++ b/src/lti/Message_Validator.php @@ -5,4 +5,3 @@ interface Message_Validator { public function validate($jwt_body); public function can_validate($jwt_body); } -?> \ No newline at end of file diff --git a/src/lti/OIDC_Exception.php b/src/lti/OIDC_Exception.php index d492f4d2..9efcce64 100644 --- a/src/lti/OIDC_Exception.php +++ b/src/lti/OIDC_Exception.php @@ -4,4 +4,3 @@ class OIDC_Exception extends \Exception { } -?> \ No newline at end of file diff --git a/src/lti/Redirect.php b/src/lti/Redirect.php index 647e8f44..20ca2e2b 100644 --- a/src/lti/Redirect.php +++ b/src/lti/Redirect.php @@ -19,7 +19,7 @@ public function do_redirect() { public function do_hybrid_redirect(Cookie $cookie = null) { if ($cookie == null) { - $cookie = new Cookie(); + $cookie = new ImsCookie(); } if (!empty($cookie->get_cookie(self::$CAN_302_COOKIE))) { return $this->do_redirect(); @@ -79,5 +79,3 @@ public function do_js_redirect() { } } - -?> \ No newline at end of file diff --git a/src/lti/lti.php b/src/lti/lti.php index 67d9a04c..5a5c2181 100644 --- a/src/lti/lti.php +++ b/src/lti/lti.php @@ -5,4 +5,3 @@ } define("TOOL_HOST", ($_SERVER['HTTP_X_FORWARDED_PROTO'] ?: $_SERVER['REQUEST_SCHEME']) . '://' . $_SERVER['HTTP_HOST']); Firebase\JWT\JWT::$leeway = 5; -?> \ No newline at end of file diff --git a/src/lti/message_validators/deep_link_message_validator.php b/src/lti/message_validators/deep_link_message_validator.php index eefb746a..2425b1ea 100644 --- a/src/lti/message_validators/deep_link_message_validator.php +++ b/src/lti/message_validators/deep_link_message_validator.php @@ -3,23 +3,23 @@ class Deep_Link_Message_Validator implements Message_Validator { public function can_validate($jwt_body) { - return $jwt_body['https://purl.imsglobal.org/spec/lti/claim/message_type'] === 'LtiDeepLinkingRequest'; + return $jwt_body[LTI_Constants::MESSAGE_TYPE] === 'LtiDeepLinkingRequest'; } public function validate($jwt_body) { if (empty($jwt_body['sub'])) { throw new LTI_Exception('Must have a user (sub)'); } - if ($jwt_body['https://purl.imsglobal.org/spec/lti/claim/version'] !== '1.3.0') { + if ($jwt_body[LTI_Constants::VERSION] !== LTI_Constants::V1_3) { throw new LTI_Exception('Incorrect version, expected 1.3.0'); } - if (!isset($jwt_body['https://purl.imsglobal.org/spec/lti/claim/roles'])) { + if (!isset($jwt_body[LTI_Constants::ROLES])) { throw new LTI_Exception('Missing Roles Claim'); } - if (empty($jwt_body['https://purl.imsglobal.org/spec/lti-dl/claim/deep_linking_settings'])) { + if (empty($jwt_body[LTI_Constants::DL_DEEP_LINK_SETTINGS])) { throw new LTI_Exception('Missing Deep Linking Settings'); } - $deep_link_settings = $jwt_body['https://purl.imsglobal.org/spec/lti-dl/claim/deep_linking_settings']; + $deep_link_settings = $jwt_body[LTI_Constants::DL_DEEP_LINK_SETTINGS]; if (empty($deep_link_settings['deep_link_return_url'])) { throw new LTI_Exception('Missing Deep Linking Return URL'); } @@ -33,4 +33,3 @@ public function validate($jwt_body) { return true; } } -?> \ No newline at end of file diff --git a/src/lti/message_validators/resource_message_validator.php b/src/lti/message_validators/resource_message_validator.php index 22521167..65559afc 100644 --- a/src/lti/message_validators/resource_message_validator.php +++ b/src/lti/message_validators/resource_message_validator.php @@ -3,24 +3,26 @@ class Resource_Message_Validator implements Message_Validator { public function can_validate($jwt_body) { - return $jwt_body['https://purl.imsglobal.org/spec/lti/claim/message_type'] === 'LtiResourceLinkRequest'; + return $jwt_body[LTI_Constants::MESSAGE_TYPE] === 'LtiResourceLinkRequest'; } public function validate($jwt_body) { if (empty($jwt_body['sub'])) { throw new LTI_Exception('Must have a user (sub)'); } - if ($jwt_body['https://purl.imsglobal.org/spec/lti/claim/version'] !== '1.3.0') { + if (!isset($jwt_body[LTI_Constants::VERSION])) { + throw new LTI_Exception('Missing LTI Version'); + } + if ($jwt_body[LTI_Constants::VERSION] !== LTI_Constants::V1_3) { throw new LTI_Exception('Incorrect version, expected 1.3.0'); } - if (!isset($jwt_body['https://purl.imsglobal.org/spec/lti/claim/roles'])) { + if (!isset($jwt_body[LTI_Constants::ROLES])) { throw new LTI_Exception('Missing Roles Claim'); } - if (empty($jwt_body['https://purl.imsglobal.org/spec/lti/claim/resource_link']['id'])) { + if (empty($jwt_body[LTI_Constants::RESOURCE_LINK]['id'])) { throw new LTI_Exception('Missing Resource Link Id'); } return true; } } -?> \ No newline at end of file